From 0d4552ef35af50b55c59247d9135f70c8406dbee Mon Sep 17 00:00:00 2001 From: Danil Iarosh Date: Tue, 8 Sep 2026 15:21:33 +0300 Subject: [PATCH] Embed background images in mapping files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - встраивать исходное изображение подложки в JSON вместе с метаданными и контрольной суммой - сохранять геометрию и параметры встроенной подложки при повторном редактировании - показывать значение «Встроенная» в диалоге подложки до выбора замены - загружать встроенное изображение непосредственно из JSON на холст - защищать существующий файл схемы при ошибке чтения подложки - расширить автоматические проверки до 161 сценария --- README.md | 2 + background_dialog.py | 25 +++++++-- canvas.py | 31 +++++++---- main_window.py | 17 +++--- models.py | 85 +++++++++++++++++++++++++++--- qa_smoke.py | 122 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 256 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index b16052a..82aa06d 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,8 @@ python -m venv .venv JSON схемы хранит `coordinate_unit` и `grid_step_mm`. При первом открытии старого файла без единицы координат приложение определяет её один раз и затем сохраняет явно. Сохранение выполняется через временный файл и атомарную замену, поэтому исходный JSON не повреждается при ошибке записи. +Если у схемы есть подложка, при сохранении её исходное PNG- или JPEG-изображение встраивается в секцию `background.embedded_image` внутри JSON вместе с именем, MIME-типом и контрольной суммой. После этого схема не зависит от внешнего файла, а размеры и параметры подложки сохраняются при редактировании и переносе JSON. Для уже встроенного и не заменённого изображения диалог `Подложка` показывает в поле пути значение `Встроенная`. Чтобы заменить его, нажмите `Выбрать` и укажите новый файл; новое изображение будет встроено при следующем сохранении схемы. + В `vm_map.h` используются физические адреса из GUI: - `strip = line_id - 1` diff --git a/background_dialog.py b/background_dialog.py index 70dfae0..7af56aa 100644 --- a/background_dialog.py +++ b/background_dialog.py @@ -24,13 +24,21 @@ except ImportError: class BackgroundDialog(QDialog): + EMBEDDED_PATH_LABEL = "Встроенная" + def __init__(self, background: BackgroundImage, parent=None) -> None: # noqa: ANN001 super().__init__(parent) self.setWindowTitle("Подложка") self.setMinimumWidth(460) - self.path = QLineEdit(background.path) + 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.setReadOnly(self._keep_embedded_image) + if self._keep_embedded_image: + source_name = background.embedded_image_name or "изображение" + self.path.setToolTip(f"{source_name} встроено в JSON схемы") browse = QPushButton("Выбрать") browse.clicked.connect(self._browse) path_row = QWidget() @@ -69,14 +77,19 @@ class BackgroundDialog(QDialog): form.addRow(buttons) def background(self) -> BackgroundImage: + embedded = self._source_background if self._keep_embedded_image else BackgroundImage() return BackgroundImage( - path=self.path.text().strip(), + path="" if self._keep_embedded_image else self.path.text().strip(), offset_x=self.offset_x.value(), offset_y=self.offset_y.value(), scale_x_percent=self.scale_x.value(), scale_y_percent=self.scale_y.value(), opacity_percent=self.opacity.value(), visible=self.visible.isChecked(), + embedded_image_base64=embedded.embedded_image_base64, + embedded_image_mime_type=embedded.embedded_image_mime_type, + embedded_image_name=embedded.embedded_image_name, + embedded_image_sha256=embedded.embedded_image_sha256, ) def _reset_scale(self) -> None: @@ -93,7 +106,13 @@ class BackgroundDialog(QDialog): "Изображения (*.png *.jpg *.jpeg);;Все файлы (*.*)", ) if selected: - self.path.setText(selected) + self._set_external_path(selected) + + def _set_external_path(self, path: str) -> None: + self._keep_embedded_image = False + self.path.setReadOnly(False) + self.path.setToolTip("") + self.path.setText(path) @staticmethod def _spin(minimum: float, maximum: float, value: float, suffix: str) -> QDoubleSpinBox: diff --git a/canvas.py b/canvas.py index 47612f8..13b72e7 100644 --- a/canvas.py +++ b/canvas.py @@ -3,7 +3,7 @@ from __future__ import annotations import math from pathlib import Path -from PySide6.QtCore import QPointF, QRectF, QSize, Qt, QTimer, Signal +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.QtWidgets import ( QApplication, @@ -93,7 +93,7 @@ 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, int | None] | None = None + self._background_pixmap_cache_key: tuple[str, str | int | None] | None = None self._background_pixmap_cache: QPixmap | None = None self._background_loaded = False self._direction_arrows_visible = True @@ -532,10 +532,7 @@ class LedCanvas(QGraphicsView): self._remove_background_items() if self.document is None or not self.document.background.is_enabled(): return - image_path = Path(self.document.background.path) - if not image_path.is_absolute() and self.document.path is not None: - image_path = self.document.path.parent / image_path - pixmap = self._load_background_pixmap(image_path) + pixmap = self._load_background_pixmap(self.document.background) if pixmap.isNull(): self._background_loaded = False return @@ -555,16 +552,32 @@ class LedCanvas(QGraphicsView): self._background_handles.clear() self._background_loaded = False - def _load_background_pixmap(self, path: Path) -> QPixmap: - cache_key = (str(path), path.stat().st_mtime_ns if path.exists() else None) + 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)) 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 - reader = QImageReader(str(path)) 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() self._background_pixmap_cache_key = cache_key self._background_pixmap_cache = pixmap diff --git a/main_window.py b/main_window.py index d1bcb2f..7760a47 100644 --- a/main_window.py +++ b/main_window.py @@ -968,15 +968,16 @@ class MainWindow(QMainWindow): if res != BackgroundDialog.Accepted: return background = dialog.background() - if not background.path: - return - 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 файл.") + 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 background.to_payload() == self.document.background.to_payload(): self.status.setText("Подложка не изменилась.") return diff --git a/models.py b/models.py index 65d3a4d..ebf2cb1 100644 --- a/models.py +++ b/models.py @@ -1,12 +1,16 @@ from __future__ import annotations +import base64 +import binascii +import hashlib import json import math +import mimetypes import os import re import tempfile from collections import deque -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from pathlib import Path from typing import Any @@ -200,11 +204,33 @@ class BackgroundImage: scale_y_percent: float = 100.0 opacity_percent: float = 100.0 visible: bool = True + embedded_image_base64: str = "" + embedded_image_mime_type: str = "" + embedded_image_name: str = "" + embedded_image_sha256: str = "" @classmethod def from_payload(cls, payload) -> "BackgroundImage": # noqa: ANN001 if not isinstance(payload, dict): return cls() + embedded = payload.get("embedded_image") + embedded_data = "" + embedded_mime_type = "" + embedded_name = "" + embedded_sha256 = "" + if isinstance(embedded, dict): + candidate_data = str(embedded.get("data") or "") + try: + decoded = base64.b64decode(candidate_data, validate=True) if candidate_data else b"" + except (binascii.Error, ValueError, TypeError): + decoded = b"" + candidate_sha256 = str(embedded.get("sha256") or "").strip().lower() + actual_sha256 = hashlib.sha256(decoded).hexdigest() if decoded else "" + if decoded and (not candidate_sha256 or candidate_sha256 == actual_sha256): + embedded_data = candidate_data + embedded_mime_type = str(embedded.get("mime_type") or "application/octet-stream") + embedded_name = Path(str(embedded.get("name") or "background")).name + embedded_sha256 = actual_sha256 scale = _optional_float(payload.get("scale_percent")) scale_x = _optional_float(payload.get("scale_x_percent")) scale_y = _optional_float(payload.get("scale_y_percent")) @@ -223,10 +249,14 @@ class BackgroundImage: scale_y_percent=max(1.0, float(scale_y)), opacity_percent=min(100.0, max(0.0, 100.0 if opacity is None else float(opacity))), visible=bool(payload.get("visible", True)), + embedded_image_base64=embedded_data, + embedded_image_mime_type=embedded_mime_type, + embedded_image_name=embedded_name, + embedded_image_sha256=embedded_sha256, ) - def to_payload(self) -> dict[str, bool | float | str]: - return { + def to_payload(self) -> dict[str, Any]: + payload: dict[str, Any] = { "path": self.path, "offset_x": self.offset_x, "offset_y": self.offset_y, @@ -235,9 +265,48 @@ class BackgroundImage: "opacity_percent": self.opacity_percent, "visible": self.visible, } + if self.has_embedded_image(): + payload["embedded_image"] = { + "encoding": "base64", + "mime_type": self.embedded_image_mime_type or "application/octet-stream", + "name": self.embedded_image_name or "background", + "sha256": self.embedded_image_sha256 or hashlib.sha256(self.embedded_image_bytes()).hexdigest(), + "data": self.embedded_image_base64, + } + return payload def is_enabled(self) -> bool: - return bool(self.path) + return bool(self.path) or self.has_embedded_image() + + def has_embedded_image(self) -> bool: + return bool(self.embedded_image_base64) + + def embedded_image_bytes(self) -> bytes: + if not self.embedded_image_base64: + return b"" + try: + return base64.b64decode(self.embedded_image_base64, validate=True) + except (binascii.Error, ValueError, TypeError): + return b"" + + def with_embedded_image(self, base_dir: Path | None = None) -> "BackgroundImage": + if self.has_embedded_image() or not self.path: + return self + image_path = Path(self.path) + if not image_path.is_absolute() and base_dir is not None: + image_path = base_dir / image_path + image_bytes = image_path.read_bytes() + if not image_bytes: + raise ValueError("Файл подложки пуст и не может быть встроен в схему.") + mime_type = mimetypes.guess_type(image_path.name)[0] or "application/octet-stream" + return replace( + self, + path="", + embedded_image_base64=base64.b64encode(image_bytes).decode("ascii"), + embedded_image_mime_type=mime_type, + embedded_image_name=image_path.name, + embedded_image_sha256=hashlib.sha256(image_bytes).hexdigest(), + ) class MappingDocument: @@ -397,7 +466,9 @@ class MappingDocument: def save(self, path: str | Path) -> None: path = Path(path) self.reindex_all() - payload = self.to_payload() + source_base_dir = self.path.parent if self.path is not None else path.parent + background_for_save = self.background.with_embedded_image(source_base_dir) + payload = self.to_payload(background=background_for_save) serialized = json.dumps(payload, ensure_ascii=False, indent=2, allow_nan=False) if json.loads(serialized) != payload: raise ValueError("Не удалось без потерь сериализовать схему.") @@ -422,6 +493,7 @@ class MappingDocument: if temporary_path is not None and temporary_path.exists(): temporary_path.unlink() self.path = path + self.background = background_for_save def to_payload( self, @@ -429,6 +501,7 @@ class MappingDocument: point_payloads: list[dict[str, Any]] | None = None, lines_payload: list[dict[str, Any]] | None = None, collection_name: str | None = None, + background: BackgroundImage | None = None, ) -> dict[str, Any]: payload = dict(self.extra_payload) payload[collection_name or self.collection_name] = ( @@ -439,7 +512,7 @@ class MappingDocument: payload["unfolding"] = self.describe_unfolding().to_payload() payload["coordinate_unit"] = self._coordinate_unit_for_save() payload["grid_step_mm"] = self.grid_step_mm - payload["background"] = self.background.to_payload() + payload["background"] = (self.background if background is None else background).to_payload() payload["lines"] = self.lines_to_payload() if lines_payload is None else lines_payload return payload diff --git a/qa_smoke.py b/qa_smoke.py index 6bd4372..4b0e2d0 100644 --- a/qa_smoke.py +++ b/qa_smoke.py @@ -1,5 +1,7 @@ from __future__ import annotations +import base64 +import hashlib import json import importlib import gc @@ -147,7 +149,10 @@ def main() -> None: test_save_preserves_unknown_source_fields, test_ordered_json_preserves_document_and_remaps_uids, test_background_load_save_roundtrip, + test_background_image_is_embedded_and_survives_source_changes, + 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_canvas_background_layer_and_geometry, test_canvas_background_zero_offset_uses_scheme_center, test_canvas_background_edit_move_and_resize_helpers, @@ -1620,6 +1625,11 @@ def test_background_load_save_roundtrip() -> None: } source = Path(temp_dir) / "map.json" source.write_text(json.dumps(payload), encoding="utf-8") + (Path(temp_dir) / "drawing.png").write_bytes( + base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" + ) + ) doc = MappingDocument() doc.load(source) assert doc.background.path == "drawing.png" @@ -1652,6 +1662,81 @@ def test_background_load_save_roundtrip() -> None: assert reloaded.background.offset_y == 0 +def test_background_image_is_embedded_and_survives_source_changes() -> None: + if LedCanvas is None or QApplication is None or QImage is None: + return + app = QApplication.instance() or QApplication([]) + with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir: + image_path = Path(temp_dir) / "background.png" + image = QImage(20, 10, QImage.Format.Format_ARGB32) + image.fill(QColor("#123456")) + assert image.save(str(image_path)) + original_bytes = image_path.read_bytes() + + doc = MappingDocument() + doc.load(path) + doc.background = BackgroundImage(str(image_path), 12.5, -7.25, 200.0, 300.0, 45.0, True) + saved = Path(temp_dir) / "embedded-map.json" + doc.save(saved) + + payload = json.loads(saved.read_text(encoding="utf-8")) + background_payload = payload["background"] + embedded_payload = background_payload["embedded_image"] + assert background_payload["path"] == "" + assert embedded_payload["encoding"] == "base64" + assert embedded_payload["mime_type"] == "image/png" + assert embedded_payload["name"] == "background.png" + assert embedded_payload["sha256"] == hashlib.sha256(original_bytes).hexdigest() + assert base64.b64decode(embedded_payload["data"], validate=True) == original_bytes + assert doc.background.has_embedded_image() + assert doc.background.path == "" + + image_path.unlink() + first_embedded_data = doc.background.embedded_image_base64 + doc.points[0].x += 1000.0 + doc.save(saved) + assert doc.background.embedded_image_base64 == first_embedded_data + + reloaded = MappingDocument() + reloaded.load(saved) + assert reloaded.background.to_payload() == doc.background.to_payload() + canvas = LedCanvas() + canvas.set_document(reloaded) + assert canvas.background_loaded() + assert canvas._background_item is not None + assert canvas._background_item.pixmap().width() == 20 + assert canvas._background_item.pixmap().height() == 10 + rect = canvas._background_scene_rect() + assert rect is not None + assert rect.width() == 40.0 + assert rect.height() == 30.0 + canvas.close() + + +def test_background_embedding_failure_keeps_document_and_target_unchanged() -> None: + with temp_document_path() as source_path, tempfile.TemporaryDirectory() as temp_dir: + doc = MappingDocument() + doc.load(source_path) + missing_path = Path(temp_dir) / "missing-background.png" + original_background = BackgroundImage(path=str(missing_path), offset_x=12.0, scale_x_percent=150.0) + doc.background = original_background + target = Path(temp_dir) / "existing-map.json" + original_target = '{"preserve": true}\n' + target.write_text(original_target, encoding="utf-8") + + try: + doc.save(target) + except FileNotFoundError: + pass + else: + raise AssertionError("Сохранение должно завершаться ошибкой, если файл подложки недоступен.") + + assert target.read_text(encoding="utf-8") == original_target + assert doc.path == source_path + assert doc.background is original_background + assert not doc.background.has_embedded_image() + + def test_background_dialog_preserves_non_uniform_scale() -> None: if QApplication is None or BackgroundDialog is None: return @@ -1665,6 +1750,43 @@ def test_background_dialog_preserves_non_uniform_scale() -> None: dialog.close() +def test_background_dialog_marks_and_preserves_embedded_image() -> None: + if QApplication is None or BackgroundDialog is None: + return + app = QApplication.instance() or QApplication([]) + image_bytes = b"embedded-background-image" + encoded = base64.b64encode(image_bytes).decode("ascii") + background = BackgroundImage( + path="", + offset_x=1.0, + offset_y=2.0, + scale_x_percent=80.0, + scale_y_percent=125.0, + opacity_percent=60.0, + visible=True, + embedded_image_base64=encoded, + embedded_image_mime_type="image/png", + embedded_image_name="source.png", + embedded_image_sha256=hashlib.sha256(image_bytes).hexdigest(), + ) + dialog = BackgroundDialog(background) + assert dialog.path.text() == "Встроенная" + assert dialog.path.isReadOnly() + dialog.scale_x.setValue(90.0) + updated = dialog.background() + assert updated.path == "" + assert updated.scale_x_percent == 90.0 + assert updated.embedded_image_base64 == encoded + assert updated.embedded_image_sha256 == background.embedded_image_sha256 + + dialog._set_external_path("replacement.jpg") + replacement = dialog.background() + assert not dialog.path.isReadOnly() + assert replacement.path == "replacement.jpg" + assert not replacement.has_embedded_image() + dialog.close() + + def test_canvas_background_layer_and_geometry() -> None: if LedCanvas is None or QApplication is None or QImage is None: return