diff --git a/LEDMapper.spec b/LEDMapper.spec index aaa0924..b49aa11 100644 --- a/LEDMapper.spec +++ b/LEDMapper.spec @@ -94,6 +94,7 @@ a = Analysis( datas=[(str(spec_dir / 'assets'), 'assets'), (str(build_version_path), '.')], hiddenimports=[ 'canvas', + 'background_image_loader', 'background_dialog', 'icons', 'localization', diff --git a/README.md b/README.md index a1a55c1..c081a75 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ LED Mapper — настольное приложение на Python и PySide6 - Продолжение существующего блока кистью от последней точки блока. - Предпросмотр зоны действия кисти и точек, которые будут добавлены. - Сетка с настраиваемым шагом (по умолчанию 34 мм) во всех режимах редактирования и генерации. -- Настраиваемый абсолютный диаметр LED-точек: визуальный размер не меняется при приближении. +- Настраиваемые абсолютные размеры LED-точек и текста их индексов: визуальный размер не меняется при приближении. - Undo/redo для операций разметки. - Удаление назначений через `Del` и контекстные меню. - Пакетное удаление выбранных блоков через дерево одной операцией с единым Undo. @@ -89,7 +89,7 @@ python -m venv .venv 6. Проверьте счетчики в верхней части боковой панели: количество линий, блоков и назначенных LED. 7. Сгенерируйте скрипт через `Файл -> Сгенерировать скрипт`. -Шаг сетки и диаметр точек меняются в `Файл -> Параметры -> Внешний вид`. Для каждого значения рядом есть кнопка `Сбросить`. Шаг сетки сохраняется в JSON текущей схемы, а диаметр точек — в пользовательских параметрах приложения. Изменение шага не перемещает уже существующие LED автоматически. +Шаг сетки, диаметр точек и размер текста индексов меняются в `Файл -> Параметры -> Внешний вид`. Для каждого значения рядом есть кнопка `Сбросить`. Размер текста индексов обновляется на схеме сразу во время настройки; при отмене возвращается прежнее значение. Шаг сетки сохраняется в JSON текущей схемы, а диаметр точек и размер текста индексов — в пользовательских параметрах приложения. Визуальные размеры точек и подписей не меняются при приближении схемы. Изменение шага не перемещает уже существующие LED автоматически. Чтобы удалить несколько блоков, выделите их в дереве с `Ctrl` или `Shift`, затем нажмите `Ctrl+Del` или выберите удаление в контекстном меню. Приложение показывает одно подтверждение и создаёт одну операцию Undo. @@ -97,6 +97,8 @@ python -m venv .venv В собранном Windows EXE приложение проверяет наличие нового стабильного релиза после запуска. В меню `Справка` можно запустить проверку вручную и, если новая версия найдена, выбрать `Установить обновление`. Загружается только артефакт вида `LEDMapper-vX.Y.Z.exe` из репозитория `Robotail/LED-mapper` на `git.robotail.pro`. После проверки размера и формата EXE приложение предлагает сохранить изменённую схему, закрывается, а отдельный процесс заменяет EXE по прежнему пути и запускает новую версию — существующие ярлыки продолжают работать. Версия релизной сборки хранится внутри EXE, поэтому переименование файла не вызывает повторную установку того же обновления. При неудачном запуске старая версия восстанавливается; причина записывается рядом с EXE в `update-error.log`. Для обновления нужны доступ к серверу релизов и право записи в каталог приложения. Из исходников можно проверить наличие версии, но автоустановка недоступна. +Перед загрузкой проверяются права записи в каталог приложения. Перенаправления на страницу входа, другой сайт или неожиданный путь того же сервера отклоняются; объявленный сервером размер файла должен совпадать с размером артефакта в данных релиза. + Если не назначен ни один светодиод, генерация не запускается. Если назначены не все светодиоды, приложение показывает предупреждение и предлагает либо сгенерировать неполный маппинг, либо продолжить редактирование. ## Горячие клавиши @@ -136,7 +138,7 @@ python -m venv .venv JSON схемы хранит `coordinate_unit`, `grid_step_mm` и `index_base: 0`. При первом открытии старого файла без единицы координат приложение определяет её один раз и затем сохраняет явно. Старые схемы с 1-based ID линий и блоков при загрузке переводятся на 0-based; сохранение записывает маркер `index_base: 0`, чтобы повторная загрузка не сдвигала ID. Сохранение выполняется через временный файл и атомарную замену, поэтому исходный JSON не повреждается при ошибке записи. -Если у схемы есть подложка, при сохранении её исходное PNG- или JPEG-изображение встраивается в секцию `background.embedded_image` внутри JSON вместе с именем, MIME-типом и контрольной суммой. После этого схема не зависит от внешнего файла, а размеры и параметры подложки сохраняются при редактировании и переносе JSON. Для уже встроенного и не заменённого изображения диалог `Подложка` показывает в поле пути значение `Встроенная`. Чтобы заменить его, нажмите `Выбрать` и укажите новый файл; новое изображение будет встроено при следующем сохранении схемы. +Если у схемы есть подложка, при сохранении исходное изображение встраивается в секцию `background.embedded_image` внутри JSON вместе с именем, MIME-типом и контрольной суммой. Диалог выбора показывает все форматы, которые доступны в поставляемой версии Qt (включая PNG, JPEG/JFIF, BMP, GIF, WebP, TIFF и SVG), распознаёт содержимое независимо от регистра и расширения файла и учитывает EXIF-ориентацию. Большие изображения безопасно уменьшаются для отображения. Если изображение повреждено или его декодер недоступен, приложение показывает причину и сохраняет прежнюю подложку. После сохранения схема не зависит от внешнего файла, а размеры и параметры подложки сохраняются при редактировании и переносе JSON. Для уже встроенного и не заменённого изображения диалог `Подложка` показывает в поле пути значение `Встроенная`. Чтобы заменить его, нажмите `Выбрать` и укажите новый файл; новое изображение будет встроено при следующем сохранении схемы. В `vm_map.h` используются физические адреса из GUI: diff --git a/background_dialog.py b/background_dialog.py index 7af56aa..b124374 100644 --- a/background_dialog.py +++ b/background_dialog.py @@ -16,9 +16,11 @@ from PySide6.QtWidgets import ( ) try: + from .background_image_loader import supported_image_filter from .localization import localize_dialog_buttons from .models import BackgroundImage except ImportError: + from background_image_loader import supported_image_filter from localization import localize_dialog_buttons from models import BackgroundImage @@ -34,7 +36,7 @@ class BackgroundDialog(QDialog): self._source_background = background self._keep_embedded_image = background.has_embedded_image() self.path = QLineEdit(self.EMBEDDED_PATH_LABEL if self._keep_embedded_image else background.path) - self.path.setPlaceholderText("PNG или JPEG") + self.path.setPlaceholderText("Файл изображения") self.path.setReadOnly(self._keep_embedded_image) if self._keep_embedded_image: source_name = background.embedded_image_name or "изображение" @@ -97,13 +99,13 @@ class BackgroundDialog(QDialog): self.scale_y.setValue(100.0) def _browse(self) -> None: - start = self.path.text().strip() + start = "" if self._keep_embedded_image else self.path.text().strip() start_path = Path(start).parent if start else Path.home() selected, _ = QFileDialog.getOpenFileName( self, "Выбрать подложку", str(start_path), - "Изображения (*.png *.jpg *.jpeg);;Все файлы (*.*)", + supported_image_filter(), ) if selected: self._set_external_path(selected) diff --git a/background_image_loader.py b/background_image_loader.py new file mode 100644 index 0000000..8f0a30c --- /dev/null +++ b/background_image_loader.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from dataclasses import dataclass +import math +from pathlib import Path + +from PySide6.QtCore import QByteArray, QBuffer, QIODevice, QSize, Qt +from PySide6.QtGui import QImage, QImageIOHandler, QImageReader + +try: + from .models import BackgroundImage +except ImportError: + from models import BackgroundImage + + +MAX_BACKGROUND_IMAGE_SIDE = 8192 +MAX_BACKGROUND_IMAGE_PIXELS = 32_000_000 +MAX_UNSCALED_SOURCE_PIXELS = 100_000_000 + + +@dataclass(frozen=True) +class BackgroundImageLoadResult: + image: QImage + error: str = "" + + @property + def loaded(self) -> bool: + return not self.image.isNull() + + +def supported_image_filter() -> str: + formats = sorted( + { + bytes(image_format).decode("ascii", errors="ignore").lower() + for image_format in QImageReader.supportedImageFormats() + if image_format + } + ) + wildcards: list[str] = [] + for image_format in formats: + if not image_format or image_format == "pdf": + continue + wildcards.extend((f"*.{image_format}", f"*.{image_format.upper()}")) + patterns = " ".join(dict.fromkeys(wildcards)) + return f"Изображения ({patterns});;Все файлы (*.*)" + + +def resolve_background_path(background: BackgroundImage, document_path: Path | None = None) -> Path: + path = Path(background.path) + if not path.is_absolute() and document_path is not None: + path = document_path.parent / path + return path + + +def background_cache_key(background: BackgroundImage, document_path: Path | None = None) -> tuple[object, ...]: + if background.has_embedded_image(): + image_bytes = background.embedded_image_bytes() + return ("embedded", background.embedded_image_sha256 or hash(image_bytes), len(image_bytes)) + path = resolve_background_path(background, document_path) + try: + stat = path.stat() + except OSError: + return ("file", str(path), None, None) + return ("file", str(path), stat.st_mtime_ns, stat.st_size) + + +def _bounded_image_size(size: QSize, max_side: int) -> QSize: + if not size.isValid() or max_side <= 0: + return size + side_factor = min(1.0, max_side / max(size.width(), size.height())) + pixel_factor = min(1.0, math.sqrt(MAX_BACKGROUND_IMAGE_PIXELS / (size.width() * size.height()))) + factor = min(side_factor, pixel_factor) + if factor >= 1.0: + return size + return QSize(max(1, math.floor(size.width() * factor)), max(1, math.floor(size.height() * factor))) + + +def load_background_image( + background: BackgroundImage, + document_path: Path | None = None, + *, + max_side: int = MAX_BACKGROUND_IMAGE_SIDE, +) -> BackgroundImageLoadResult: + buffer: QBuffer | None = None + if background.has_embedded_image(): + image_bytes = background.embedded_image_bytes() + if not image_bytes: + return BackgroundImageLoadResult(QImage(), "Встроенные данные изображения повреждены или пусты.") + buffer = QBuffer() + buffer.setData(QByteArray(image_bytes)) + if not buffer.open(QIODevice.OpenModeFlag.ReadOnly): + return BackgroundImageLoadResult(QImage(), "Не удалось открыть встроенные данные изображения.") + reader = QImageReader(buffer) + source_name = background.embedded_image_name or "встроенное изображение" + else: + path = resolve_background_path(background, document_path) + if not path.is_file(): + return BackgroundImageLoadResult(QImage(), f"Файл не найден: {path}") + reader = QImageReader(str(path)) + source_name = path.name + + previous_allocation_limit: int | None = None + try: + reader.setAutoTransform(True) + reader.setDecideFormatFromContent(True) + size = reader.size() + bounded_size = _bounded_image_size(size, max_side) + if bounded_size != size: + if not reader.supportsOption(QImageIOHandler.ImageOption.ScaledSize): + source_pixels = size.width() * size.height() + if source_pixels > MAX_UNSCALED_SOURCE_PIXELS: + return BackgroundImageLoadResult( + QImage(), + f"Изображение «{source_name}» слишком большое для безопасного декодирования " + f"({size.width()}×{size.height()} пикселей).", + ) + current_allocation_limit = QImageReader.allocationLimit() + required_megabytes = math.ceil(source_pixels * 8 / (1024 * 1024)) + 16 + if current_allocation_limit > 0 and required_megabytes > current_allocation_limit: + previous_allocation_limit = current_allocation_limit + QImageReader.setAllocationLimit(required_megabytes) + reader.setScaledSize(bounded_size) + image = reader.read() + if image.isNull(): + details = reader.errorString().strip() + message = f"Не удалось декодировать «{source_name}»" + if details: + message += f": {details}" + return BackgroundImageLoadResult(QImage(), message + ".") + + bounded_size = _bounded_image_size(image.size(), max_side) + if bounded_size != image.size(): + image = image.scaled( + bounded_size, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + if image.format() not in (QImage.Format.Format_ARGB32, QImage.Format.Format_ARGB32_Premultiplied): + image = image.convertToFormat(QImage.Format.Format_ARGB32_Premultiplied) + if image.isNull(): + return BackgroundImageLoadResult(QImage(), f"Не удалось преобразовать «{source_name}» для отображения.") + return BackgroundImageLoadResult(image) + finally: + if previous_allocation_limit is not None: + QImageReader.setAllocationLimit(previous_allocation_limit) + if buffer is not None: + buffer.close() diff --git a/canvas.py b/canvas.py index 13b72e7..d60e7df 100644 --- a/canvas.py +++ b/canvas.py @@ -1,10 +1,9 @@ from __future__ import annotations import math -from pathlib import Path -from PySide6.QtCore import QByteArray, QBuffer, QIODevice, QPointF, QRectF, QSize, Qt, QTimer, Signal -from PySide6.QtGui import QColor, QBrush, QImage, QImageReader, QPainter, QPainterPath, QPen, QPixmap, QTransform +from PySide6.QtCore import QPointF, QRectF, Qt, QTimer, Signal +from PySide6.QtGui import QColor, QBrush, QFont, QPainter, QPainterPath, QPen, QPixmap, QTransform from PySide6.QtWidgets import ( QApplication, QGraphicsEllipseItem, @@ -22,12 +21,13 @@ from PySide6.QtWidgets import ( from shiboken6 import isValid try: + from .background_image_loader import background_cache_key, load_background_image from .models import BackgroundImage, LedLine, LedPoint, MappingDocument, coordinate_display_scale except ImportError: + from background_image_loader import background_cache_key, load_background_image from models import BackgroundImage, LedLine, LedPoint, MappingDocument, coordinate_display_scale -MAX_BACKGROUND_PIXMAP_SIDE = 8192 MAX_GRID_LINES = 800 @@ -93,11 +93,13 @@ class LedCanvas(QGraphicsView): self._background_handles: dict[str, QGraphicsRectItem] = {} self._background_edit_mode = False self._background_drag: dict | None = None - self._background_pixmap_cache_key: tuple[str, str | int | None] | None = None + self._background_pixmap_cache_key: tuple[object, ...] | None = None self._background_pixmap_cache: QPixmap | None = None + self._background_load_error = "" self._background_loaded = False self._direction_arrows_visible = True self._line_index_labels_visible = True + self._line_index_font_size_px = 12 self._point_radius = 4.5 self._brush_radius = 18.0 self._zoom_focus_pull = 0.35 @@ -238,6 +240,18 @@ class LedCanvas(QGraphicsView): def line_index_labels_visible(self) -> bool: return self._line_index_labels_visible + def set_line_index_font_size(self, font_size_px: int) -> None: + font_size = max(1, min(256, int(font_size_px))) + if self._line_index_font_size_px == font_size: + return + self._line_index_font_size_px = font_size + for label in self.line_index_label_items.values(): + if label is not None and isValid(label): + font = label.font() + font.setPixelSize(font_size) + label.setFont(font) + self._position_line_index_labels() + def set_brush_radius(self, radius: int) -> None: self._brush_radius = float(max(4, min(80, radius))) if self._brush_zone_item is not None and isValid(self._brush_zone_item): @@ -318,6 +332,15 @@ class LedCanvas(QGraphicsView): def background_loaded(self) -> bool: return self._background_loaded + def background_load_error(self) -> str: + return self._background_load_error + + def validate_background(self, background: BackgroundImage) -> bool: + if not background.is_enabled(): + self._background_load_error = "Не выбран файл изображения подложки." + return False + return not self._load_background_pixmap(background).isNull() + def set_background(self, background: BackgroundImage) -> None: if self.document is None: return @@ -553,32 +576,13 @@ class LedCanvas(QGraphicsView): self._background_loaded = False def _load_background_pixmap(self, background: BackgroundImage) -> QPixmap: - buffer: QBuffer | None = None - if background.has_embedded_image(): - image_bytes = background.embedded_image_bytes() - cache_key = ("embedded", background.embedded_image_sha256 or len(image_bytes)) - buffer = QBuffer() - buffer.setData(QByteArray(image_bytes)) - buffer.open(QIODevice.OpenModeFlag.ReadOnly) - reader = QImageReader(buffer) - else: - path = Path(background.path) - if not path.is_absolute() and self.document is not None and self.document.path is not None: - path = self.document.path.parent / path - cache_key = (str(path), path.stat().st_mtime_ns if path.exists() else None) - reader = QImageReader(str(path)) + document_path = self.document.path if self.document is not None else None + cache_key = background_cache_key(background, document_path) if self._background_pixmap_cache_key == cache_key and self._background_pixmap_cache is not None: - if buffer is not None: - buffer.close() return self._background_pixmap_cache - size = reader.size() - if size.isValid() and max(size.width(), size.height()) > MAX_BACKGROUND_PIXMAP_SIDE: - factor = MAX_BACKGROUND_PIXMAP_SIDE / max(size.width(), size.height()) - reader.setScaledSize(QSize(max(1, int(size.width() * factor)), max(1, int(size.height() * factor)))) - image = reader.read() - if buffer is not None: - buffer.close() - pixmap = QPixmap.fromImage(image) if not image.isNull() else QPixmap() + result = load_background_image(background, document_path) + self._background_load_error = result.error + pixmap = QPixmap.fromImage(result.image) if result.loaded else QPixmap() self._background_pixmap_cache_key = cache_key self._background_pixmap_cache = pixmap return pixmap @@ -1404,6 +1408,9 @@ class LedCanvas(QGraphicsView): if item is None or not isValid(item): continue label = QGraphicsSimpleTextItem(str(point.line_index)) + font = QFont(label.font()) + font.setPixelSize(self._line_index_font_size_px) + label.setFont(font) label.setBrush(QColor("#f8fafc" if self._theme == "dark" else "#111827")) label.setZValue(40) label.setFlag(QGraphicsItem.ItemIgnoresTransformations, True) diff --git a/main_window.py b/main_window.py index b48b8ee..9cee3b3 100644 --- a/main_window.py +++ b/main_window.py @@ -5,7 +5,7 @@ import sys from threading import Event, Thread from PySide6.QtCore import QEvent, QObject, QSignalBlocker, QSize, Qt, QTimer, Signal -from PySide6.QtGui import QAction, QColor, QImageReader, QKeySequence +from PySide6.QtGui import QAction, QColor, QKeySequence from PySide6.QtWidgets import ( QButtonGroup, QAbstractItemView, @@ -603,15 +603,23 @@ class MainWindow(QMainWindow): if not hasattr(self, "menu_buttons_widget") or self.menu_buttons_widget is None: return menu_bar = self.menuBar() - actions = menu_bar.actions() - if len(actions) < 4: + action_rects = [ + menu_bar.actionGeometry(action) + for action in menu_bar.actions() + if action.isVisible() + ] + action_rects = [rect for rect in action_rects if rect.isValid() and rect.right() > 0] + if not action_rects: + self.menu_buttons_widget.hide() return - tools_action = actions[3] - rect = menu_bar.actionGeometry(tools_action) - if rect.isValid() and rect.right() > 0: - h = menu_bar.height() - w = self.menu_buttons_widget.sizeHint().width() - self.menu_buttons_widget.setGeometry(rect.right() + 10, (h - 20) // 2, w, 20) + left = max(rect.right() for rect in action_rects) + 10 + height = menu_bar.height() + width = self.menu_buttons_widget.sizeHint().width() + if left + width > menu_bar.contentsRect().right() + 1: + self.menu_buttons_widget.hide() + return + self.menu_buttons_widget.setGeometry(left, max(0, (height - 20) // 2), width, 20) + self.menu_buttons_widget.show() def _build_layout(self) -> None: side = QWidget() @@ -1026,14 +1034,10 @@ class MainWindow(QMainWindow): background = dialog.background() if not background.is_enabled(): return - if not background.has_embedded_image(): - image_path = Path(background.path) - if not image_path.is_absolute() and self.document.path is not None: - image_path = self.document.path.parent / image_path - reader = QImageReader(str(image_path)) - if not reader.canRead(): - show_warning(self, "Подложка не загружена", "Не удалось загрузить изображение подложки. Выберите PNG или JPEG файл.") - return + if not self.canvas.validate_background(background): + details = self.canvas.background_load_error() or "Формат или содержимое изображения не поддерживается." + show_warning(self, "Подложка не загружена", details) + return if background.to_payload() == self.document.background.to_payload(): self.status.setText("Подложка не изменилась.") return @@ -1118,12 +1122,15 @@ class MainWindow(QMainWindow): dialog_settings = deepcopy(self.settings) dialog_settings.grid_step_mm = self.document.grid_step_mm dialog = SettingsDialog(dialog_settings, self) + previous_index_font_size = self.settings.led_index_font_size_px + dialog.led_index_font_size.valueChanged.connect(self.canvas.set_line_index_font_size) self._disable_shortcuts() try: res = dialog.exec() finally: self._enable_shortcuts() if res != SettingsDialog.Accepted: + self.canvas.set_line_index_font_size(previous_index_font_size) return new_settings = dialog.settings() grid_changed = abs(self.document.grid_step_mm - new_settings.grid_step_mm) > 1e-9 @@ -1168,6 +1175,7 @@ class MainWindow(QMainWindow): self.canvas.set_grid_visible(self.settings.show_grid_by_default) self.canvas.set_direction_arrows_visible(self.settings.show_direction_arrows_by_default) self.canvas.set_line_index_labels_visible(self.settings.show_led_ids_by_default) + self.canvas.set_line_index_font_size(self.settings.led_index_font_size_px) self.canvas.set_brush_radius(self.settings.brush_radius) self.canvas.set_grid_step_mm(self.document.grid_step_mm) self.canvas.set_point_diameter(self.settings.led_point_diameter_px) diff --git a/qa_smoke.py b/qa_smoke.py index 1a91adb..f90a705 100644 --- a/qa_smoke.py +++ b/qa_smoke.py @@ -31,7 +31,9 @@ from app.models import ( order_points_like_grid_svg, ) from app.panel_mapping_generator import generate_panel_mapping_from_document +from app.background_image_loader import MAX_BACKGROUND_IMAGE_PIXELS, _bounded_image_size, load_background_image, supported_image_filter from app.update_service import UpdateError, check_for_update, download_update +import app.settings as settings_module import app.update_service as updater from app.unfolding import MAX_LINES try: @@ -44,8 +46,8 @@ try: from app.settings import DEFAULT_SHORTCUTS, AppSettings from app.settings_dialog import SettingsDialog from app.theme import apply_app_theme, dark_stylesheet, effective_theme, light_stylesheet - from PySide6.QtCore import QPoint, QPointF, QRectF, Qt - from PySide6.QtGui import QColor, QImage, QKeySequence + from PySide6.QtCore import QPoint, QPointF, QRectF, QSize, Qt + from PySide6.QtGui import QColor, QImage, QImageIOHandler, QImageWriter, QKeySequence from PySide6.QtTest import QTest from PySide6.QtWidgets import QApplication, QDialogButtonBox, QGraphicsItem, QLineEdit, QMessageBox, QToolBar except ImportError: @@ -71,9 +73,12 @@ except ImportError: QPoint = None QPointF = None QRectF = None + QSize = None Qt = None QColor = None QImage = None + QImageIOHandler = None + QImageWriter = None QKeySequence = None QApplication = None QDialogButtonBox = None @@ -145,6 +150,7 @@ def main() -> None: test_uid_point_id_mode_does_not_fallback_to_source_ids, test_panel_mapping_generation_from_document, test_update_release_validation_and_download, + test_update_download_redirects_permissions_and_cancellation, test_update_install_keeps_executable_path_and_bundled_version, test_ordered_json_meter_coordinates_reload_as_mm, test_legacy_cleanup_waits_for_success, @@ -164,6 +170,9 @@ def main() -> None: test_background_embedding_failure_keeps_document_and_target_unchanged, test_background_dialog_preserves_non_uniform_scale, test_background_dialog_marks_and_preserves_embedded_image, + test_background_loader_supports_content_detection_and_exif_orientation, + test_background_dialog_filter_uses_available_qt_formats, + test_canvas_background_decode_failure_is_reported, test_canvas_background_layer_and_geometry, test_canvas_background_zero_offset_uses_scheme_center, test_canvas_background_edit_move_and_resize_helpers, @@ -175,6 +184,7 @@ def main() -> None: test_canvas_background_changes_preserve_camera, test_canvas_uses_grid_svg_coordinates, test_canvas_shows_selected_line_index_labels, + test_led_index_font_size_keeps_absolute_screen_size, test_canvas_rebuild_can_preserve_user_zoom, test_grid_toggle_adds_grid_items, test_edit_mode_keeps_regular_grid_spacing, @@ -202,6 +212,7 @@ def main() -> None: test_main_window_direction_arrow_action_toggles_canvas, test_settings_apply_to_canvas_and_generation, test_settings_dialog_has_requested_categories, + test_app_settings_persist_led_index_font_size, test_theme_resolution_and_canvas_apply, test_menu_disabled_items_are_visually_distinct, test_shortcut_settings_are_editable_and_layout_independent, @@ -1291,9 +1302,12 @@ def test_panel_mapping_generation_from_document() -> None: def test_update_release_validation_and_download() -> None: class FakeResponse(io.BytesIO): - def __init__(self, data: bytes, url: str) -> None: + def __init__(self, data: bytes, url: str, content_length: int | None = None) -> None: super().__init__(data) self.url = url + self.headers = {} + if content_length is not None: + self.headers["Content-Length"] = str(content_length) def geturl(self) -> str: return self.url @@ -1337,6 +1351,76 @@ def test_update_release_validation_and_download() -> None: raise AssertionError("An update on another host must be rejected") +def test_update_download_redirects_permissions_and_cancellation() -> None: + class FakeResponse(io.BytesIO): + def __init__(self, data: bytes, url: str, content_length: int | None = None) -> None: + super().__init__(data) + self.url = url + self.headers = {} + if content_length is not None: + self.headers["Content-Length"] = str(content_length) + + def geturl(self) -> str: + return self.url + + tag = "v3.2.1" + name = f"LEDMapper-{tag}.exe" + url = f"https://git.robotail.pro/Robotail/LED-mapper/releases/download/{tag}/{name}" + payload = bytearray(256) + payload[:2] = b"MZ" + payload[0x3C:0x40] = (128).to_bytes(4, "little") + payload[128:134] = b"PE\x00\x00\x64\x86" + info = updater.UpdateInfo(tag, "3.2.1", name, url, len(payload)) + + with tempfile.TemporaryDirectory() as temp_dir: + directory = Path(temp_dir) + for redirect in ( + "https://git.robotail.pro/user/login", + "https://git.robotail.pro/Robotail/LED-mapper/releases/download/unexpected/update.exe", + ): + try: + download_update( + info, + directory, + opener=lambda _request, redirect=redirect, **_kwargs: FakeResponse(bytes(payload), redirect), + ) + except UpdateError: + assert not list(directory.glob(".ledmapper-update-*")) + else: + raise AssertionError("A same-host redirect outside the expected asset path must be rejected") + + try: + download_update( + info, + directory, + opener=lambda _request, **_kwargs: FakeResponse(bytes(payload), url, len(payload) + 1), + ) + except UpdateError: + assert not list(directory.glob(".ledmapper-update-*")) + else: + raise AssertionError("A conflicting Content-Length must be rejected") + + with patch.object(updater.os, "access", return_value=False): + try: + download_update(info, directory, opener=lambda _request, **_kwargs: FakeResponse(bytes(payload), url)) + except UpdateError as exc: + assert "Нет прав" in str(exc) + else: + raise AssertionError("A read-only application directory must be rejected before download") + + try: + download_update( + info, + directory, + opener=lambda _request, **_kwargs: FakeResponse(bytes(payload), url), + cancelled=lambda: True, + ) + except updater.UpdateCancelled: + assert not list(directory.glob(".ledmapper-update-*")) + else: + raise AssertionError("A cancelled download must remove its partial file") + + def test_update_install_keeps_executable_path_and_bundled_version() -> None: with tempfile.TemporaryDirectory() as temp_dir: directory = Path(temp_dir) @@ -1362,6 +1446,13 @@ def test_update_install_keeps_executable_path_and_bundled_version() -> None: updater.launch_update(staged, info, process_id=123) arguments = popen.call_args.args[0] assert arguments[8] == arguments[9] == str(old_executable) + creation_flags = popen.call_args.kwargs["creationflags"] + assert creation_flags & getattr(updater.subprocess, "CREATE_NO_WINDOW", 0) == getattr( + updater.subprocess, "CREATE_NO_WINDOW", 0 + ) + assert creation_flags & getattr(updater.subprocess, "CREATE_NEW_PROCESS_GROUP", 0) == getattr( + updater.subprocess, "CREATE_NEW_PROCESS_GROUP", 0 + ) helper = Path(arguments[5]) script = helper.read_text(encoding="utf-8-sig") assert "Wait-Process" in script and "$moveDeadline" in script @@ -1737,6 +1828,8 @@ def test_menu_bar_tools_buttons() -> None: return app = QApplication.instance() or QApplication([]) window = MainWindow() + window.resize(900, 420) + window.show() app.processEvents() from PySide6.QtWidgets import QToolButton, QFrame @@ -1760,6 +1853,22 @@ def test_menu_bar_tools_buttons() -> None: for key in expected_keys: assert window.actions[key] in button_actions + help_action = next(action for action in window.menuBar().actions() if action.text() == "Справка") + help_rect = window.menuBar().actionGeometry(help_action) + assert container.isVisible() + assert container.geometry().left() >= help_rect.right() + 10 + + window.resize(593, 420) + app.processEvents() + if container.isVisible(): + help_rect = window.menuBar().actionGeometry(help_action) + assert container.geometry().left() >= help_rect.right() + 10 + assert container.geometry().right() <= window.menuBar().contentsRect().right() + + window.resize(360, 420) + app.processEvents() + assert not container.isVisible() + window.close() @@ -2022,6 +2131,63 @@ def test_background_dialog_marks_and_preserves_embedded_image() -> None: dialog.close() +def test_background_loader_supports_content_detection_and_exif_orientation() -> None: + if QImage is None or QImageWriter is None or QImageIOHandler is None: + return + with tempfile.TemporaryDirectory() as temp_dir: + disguised_path = Path(temp_dir) / "background.unsupported-extension" + image = QImage(13, 9, QImage.Format.Format_ARGB32) + image.fill(QColor("#254769")) + assert image.save(str(disguised_path), "PNG") + disguised = load_background_image(BackgroundImage(str(disguised_path))) + assert disguised.loaded + assert disguised.image.size() == image.size() + + oriented_path = Path(temp_dir) / "oriented.jpg" + writer = QImageWriter(str(oriented_path), b"jpeg") + writer.setTransformation(QImageIOHandler.Transformation.TransformationRotate90) + assert writer.write(image), writer.errorString() + del writer + oriented = load_background_image(BackgroundImage(str(oriented_path))) + assert oriented.loaded + assert oriented.image.width() == image.height() + assert oriented.image.height() == image.width() + + cmyk_path = Path(temp_dir) / "cmyk.jpg" + cmyk_image = QImage(11, 8, QImage.Format.Format_CMYK8888) + cmyk_image.fill(QColor("#cc3311")) + assert cmyk_image.save(str(cmyk_path), "JPEG") + cmyk = load_background_image(BackgroundImage(str(cmyk_path))) + assert cmyk.loaded + assert cmyk.image.format() == QImage.Format.Format_ARGB32_Premultiplied + + +def test_background_dialog_filter_uses_available_qt_formats() -> None: + image_filter = supported_image_filter() + assert "*.png" in image_filter + assert "*.PNG" in image_filter + assert "*.jpg" in image_filter or "*.jpeg" in image_filter + assert "Все файлы (*.*)" in image_filter + + +def test_canvas_background_decode_failure_is_reported() -> None: + if LedCanvas is None or QApplication is None: + return + app = QApplication.instance() or QApplication([]) + with temp_document_path() as document_path, tempfile.TemporaryDirectory() as temp_dir: + invalid_path = Path(temp_dir) / "damaged.png" + invalid_path.write_bytes(b"not-an-image") + document = MappingDocument() + document.load(document_path) + canvas = LedCanvas() + canvas.set_document(document) + original_background = document.background + assert not canvas.validate_background(BackgroundImage(str(invalid_path))) + assert "Не удалось декодировать" in canvas.background_load_error() + assert document.background is original_background + canvas.close() + + def test_canvas_background_layer_and_geometry() -> None: if LedCanvas is None or QApplication is None or QImage is None: return @@ -2150,6 +2316,12 @@ def test_canvas_background_large_image_downsamples_and_grid_degrades() -> None: assert len(canvas.grid_items) <= 802 canvas.close() + if QSize is not None: + bounded = _bounded_image_size(QSize(12000, 12000), 8192) + assert bounded.width() <= 8192 + assert bounded.height() <= 8192 + assert bounded.width() * bounded.height() <= MAX_BACKGROUND_IMAGE_PIXELS + def test_main_window_background_reset_noops_at_100() -> None: if MainWindow is None or QApplication is None or QImage is None: @@ -2363,6 +2535,32 @@ def test_canvas_shows_selected_line_index_labels() -> None: canvas.close() +def test_led_index_font_size_keeps_absolute_screen_size() -> None: + if LedCanvas is None or QApplication is None: + return + app = QApplication.instance() or QApplication([]) + with temp_document_path() as path: + doc = MappingDocument() + doc.load(path) + line = doc.add_line() + doc.add_block(line.id, [0, 1]) + canvas = LedCanvas() + canvas.set_document(doc) + canvas.select_points([0]) + + canvas.set_line_index_font_size(22) + label = canvas.line_index_label_items[0] + initial_height = label.boundingRect().height() + assert label.font().pixelSize() == 22 + assert label.flags() & QGraphicsItem.ItemIgnoresTransformations + + canvas.scale(7.0, 7.0) + canvas._position_line_index_labels() + assert label.font().pixelSize() == 22 + assert abs(label.boundingRect().height() - initial_height) < 1e-9 + canvas.close() + + def test_canvas_rebuild_can_preserve_user_zoom() -> None: if LedCanvas is None or QApplication is None: return @@ -3065,6 +3263,7 @@ def test_settings_apply_to_canvas_and_generation() -> None: window.settings.pan_mode = "space" window.settings.instant_space_pan = True window.settings.led_point_diameter_px = 17 + window.settings.led_index_font_size_px = 21 window.document.grid_step_mm = 47.5 window.settings.default_output_dir = temp_dir window.settings.confirm_partial_generation = False @@ -3081,6 +3280,7 @@ def test_settings_apply_to_canvas_and_generation() -> None: assert window.canvas._brush_radius == 31 assert window.canvas._grid_step == 47.5 assert abs(window.canvas._point_radius - 8.5) < 1e-9 + assert window.canvas._line_index_font_size_px == 21 assert abs(window.canvas._zoom_focus_pull - 0.12) < 0.001 assert window.canvas._pan_mode == "space" assert window.canvas._instant_space_pan is True @@ -3105,16 +3305,32 @@ def test_settings_dialog_has_requested_categories() -> None: assert dialog.instant_space_pan.isChecked() is False dialog.grid_step.setValue(50.0) dialog.led_point_diameter.setValue(17) + dialog.led_index_font_size.setValue(21) updated = dialog.settings() assert updated.grid_step_mm == 50.0 assert updated.led_point_diameter_px == 17 + assert updated.led_index_font_size_px == 21 dialog.grid_step_reset.click() dialog.led_point_diameter_reset.click() + dialog.led_index_font_size_reset.click() assert dialog.grid_step.value() == 34.0 assert dialog.led_point_diameter.value() == 9 + assert dialog.led_index_font_size.value() == 12 dialog.close() +def test_app_settings_persist_led_index_font_size() -> None: + if AppSettings is None: + return + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "settings.json" + with patch.object(settings_module, "settings_path", return_value=path): + settings = AppSettings(led_index_font_size_px=23) + settings.save() + loaded = AppSettings.load() + assert loaded.led_index_font_size_px == 23 + + def test_theme_resolution_and_canvas_apply() -> None: if QApplication is None or MainWindow is None or effective_theme is None: return diff --git a/settings.py b/settings.py index b324d0d..a935fbb 100644 --- a/settings.py +++ b/settings.py @@ -44,7 +44,7 @@ SHORTCUT_DEFINITIONS: list[tuple[str, str, str]] = [ ("brush", "Кисть", "B"), ] DEFAULT_SHORTCUTS = {key: shortcut for key, _title, shortcut in SHORTCUT_DEFINITIONS} -SETTINGS_VERSION = 3 +SETTINGS_VERSION = 4 SHORTCUT_DEFAULTS_MIGRATION_VERSION = 2 DEFAULT_GRID_STEP_MM = 34.0 MIN_GRID_STEP_MM = 1.0 @@ -52,6 +52,9 @@ MAX_GRID_STEP_MM = 1000.0 DEFAULT_LED_POINT_DIAMETER_PX = 9 MIN_LED_POINT_DIAMETER_PX = 1 MAX_LED_POINT_DIAMETER_PX = 64 +DEFAULT_LED_INDEX_FONT_SIZE_PX = 12 +MIN_LED_INDEX_FONT_SIZE_PX = 6 +MAX_LED_INDEX_FONT_SIZE_PX = 64 @dataclass @@ -63,6 +66,7 @@ class AppSettings: show_led_ids_by_default: bool = True grid_step_mm: float = DEFAULT_GRID_STEP_MM led_point_diameter_px: int = DEFAULT_LED_POINT_DIAMETER_PX + led_index_font_size_px: int = DEFAULT_LED_INDEX_FONT_SIZE_PX zoom_focus_pull: int = 35 pan_mode: str = "middle_space" instant_space_pan: bool = False @@ -99,6 +103,12 @@ class AppSettings: MAX_LED_POINT_DIAMETER_PX, DEFAULT_LED_POINT_DIAMETER_PX, ) + settings.led_index_font_size_px = clamp_int( + settings.led_index_font_size_px, + MIN_LED_INDEX_FONT_SIZE_PX, + MAX_LED_INDEX_FONT_SIZE_PX, + DEFAULT_LED_INDEX_FONT_SIZE_PX, + ) settings.zoom_focus_pull = clamp_int(settings.zoom_focus_pull, 0, 100, cls.zoom_focus_pull) settings.brush_radius = clamp_int(settings.brush_radius, 4, 80, cls.brush_radius) if settings.theme not in {"system", "light", "dark"}: diff --git a/settings_dialog.py b/settings_dialog.py index 2b458db..89f34be 100644 --- a/settings_dialog.py +++ b/settings_dialog.py @@ -30,11 +30,14 @@ try: from .localization import localize_dialog_buttons, show_warning from .settings import ( DEFAULT_GRID_STEP_MM, + DEFAULT_LED_INDEX_FONT_SIZE_PX, DEFAULT_LED_POINT_DIAMETER_PX, DEFAULT_SHORTCUTS, MAX_GRID_STEP_MM, + MAX_LED_INDEX_FONT_SIZE_PX, MAX_LED_POINT_DIAMETER_PX, MIN_GRID_STEP_MM, + MIN_LED_INDEX_FONT_SIZE_PX, MIN_LED_POINT_DIAMETER_PX, SETTINGS_VERSION, SHORTCUT_DEFINITIONS, @@ -44,11 +47,14 @@ except ImportError: from localization import localize_dialog_buttons, show_warning from settings import ( DEFAULT_GRID_STEP_MM, + DEFAULT_LED_INDEX_FONT_SIZE_PX, DEFAULT_LED_POINT_DIAMETER_PX, DEFAULT_SHORTCUTS, MAX_GRID_STEP_MM, + MAX_LED_INDEX_FONT_SIZE_PX, MAX_LED_POINT_DIAMETER_PX, MIN_GRID_STEP_MM, + MIN_LED_INDEX_FONT_SIZE_PX, MIN_LED_POINT_DIAMETER_PX, SETTINGS_VERSION, SHORTCUT_DEFINITIONS, @@ -95,6 +101,17 @@ class SettingsDialog(QDialog): self.led_point_diameter_reset.clicked.connect( lambda: self.led_point_diameter.setValue(DEFAULT_LED_POINT_DIAMETER_PX) ) + self.led_index_font_size = QSpinBox() + self.led_index_font_size.setRange( + MIN_LED_INDEX_FONT_SIZE_PX, + MAX_LED_INDEX_FONT_SIZE_PX, + ) + self.led_index_font_size.setSuffix(" px") + self.led_index_font_size.setValue(settings.led_index_font_size_px) + self.led_index_font_size_reset = QPushButton("Сбросить") + self.led_index_font_size_reset.clicked.connect( + lambda: self.led_index_font_size.setValue(DEFAULT_LED_INDEX_FONT_SIZE_PX) + ) self.zoom_pull = QSpinBox() self.zoom_pull.setRange(0, 100) self.zoom_pull.setSuffix(" %") @@ -159,6 +176,7 @@ class SettingsDialog(QDialog): show_led_ids_by_default=self.show_led_ids.isChecked(), grid_step_mm=self.grid_step.value(), led_point_diameter_px=self.led_point_diameter.value(), + led_index_font_size_px=self.led_index_font_size.value(), zoom_focus_pull=self.zoom_pull.value(), pan_mode=str(self.pan_mode.currentData()), instant_space_pan=self.instant_space_pan.isChecked(), @@ -180,6 +198,10 @@ class SettingsDialog(QDialog): "Диаметр LED-точек", self._setting_with_reset(self.led_point_diameter, self.led_point_diameter_reset), ) + layout.addRow( + "Размер текста индексов LED", + self._setting_with_reset(self.led_index_font_size, self.led_index_font_size_reset), + ) layout.addRow("Притяжение зума к курсору", self.zoom_pull) layout.addRow(QLabel("0% оставляет центр спокойнее, 100% сильнее тянет схему к курсору.")) return page diff --git a/update_service.py b/update_service.py index 9dfd936..6d7036a 100644 --- a/update_service.py +++ b/update_service.py @@ -164,10 +164,13 @@ def download_update( target_dir = directory.resolve() if not target_dir.is_dir(): raise UpdateError("Каталог приложения не найден") + if not os.access(target_dir, os.W_OK): + raise UpdateError("Нет прав на запись в каталог приложения") if not _trusted_url(info.download_url) or urlparse(info.download_url).path != ( f"/Robotail/LED-mapper/releases/download/{info.tag}/{info.asset_name}" ): raise UpdateError("Недопустимый адрес загрузки обновления") + expected_path = f"/Robotail/LED-mapper/releases/download/{info.tag}/{info.asset_name}" path: Path | None = None received = 0 request = Request(info.download_url, headers={"User-Agent": "LEDMapper-Updater"}) @@ -175,6 +178,19 @@ def download_update( with (opener or urlopen)(request, timeout=timeout) as response: if not _trusted_url(response.geturl()): raise UpdateError("Загрузка перенаправлена на другой сайт") + final_path = urlparse(response.geturl()).path + if final_path == "/user/login": + raise UpdateError("Release-артефакты недоступны без авторизации") + if final_path != expected_path: + raise UpdateError("Загрузка перенаправлена на неожиданный адрес") + content_length = getattr(response, "headers", {}).get("Content-Length") + if content_length is not None: + try: + declared_size = int(content_length) + except (TypeError, ValueError) as exc: + raise UpdateError("Сервер вернул некорректный размер обновления") from exc + if declared_size != info.size: + raise UpdateError("Размер обновления не совпадает с данными релиза") with tempfile.NamedTemporaryFile( mode="wb", dir=target_dir, prefix=".ledmapper-update-", suffix=".download", delete=False ) as output: @@ -296,6 +312,8 @@ def launch_update(downloaded: Path, info: UpdateInfo, *, process_id: int | None source = downloaded.resolve() if platform.system() != "Windows": raise UpdateError("Установка обновления доступна только в Windows") + if not os.access(old_target.parent, os.W_OK): + raise UpdateError("Нет прав на замену приложения") if source.parent != old_target.parent or not source.is_file() or not source.name.startswith(".ledmapper-update-"): raise UpdateError("Скачанный файл должен находиться рядом с приложением") if not EXECUTABLE_RE.fullmatch(info.asset_name): @@ -310,6 +328,10 @@ def launch_update(downloaded: Path, info: UpdateInfo, *, process_id: int | None ready_path = old_target.parent / f".ledmapper-ready-{token}" try: _write_windows_updater(script_path) + creation_flags = ( + getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) + | getattr(subprocess, "CREATE_NO_WINDOW", 0) + ) subprocess.Popen( [ "powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", @@ -317,7 +339,7 @@ def launch_update(downloaded: Path, info: UpdateInfo, *, process_id: int | None str(new_target), str(backup_path), str(ready_path), ], close_fds=True, - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + creationflags=creation_flags, ) except (OSError, UpdateError) as exc: script_path.unlink(missing_ok=True) diff --git a/version.py b/version.py index 24fd45d..4b96aa9 100644 --- a/version.py +++ b/version.py @@ -1,3 +1,3 @@ """Текущая версия приложения; обновляется при публикации релиза.""" -__version__ = "3.3.1" +__version__ = "3.4.0"