- Добавлена сохраняемая настройка размера текста индексов LED с мгновенным предпросмотром и сбросом. - Исправлено размещение быстрых кнопок в строке меню без перекрытия пунктов на узком окне. - Усилена проверка OTA-загрузок: права записи, конечный URL, авторизация, Content-Length и отдельная группа updater-процесса. - Добавлен единый загрузчик подложек с определением формата по содержимому, EXIF-ориентацией и преобразованием CMYK. - Расширен список форматов подложки и добавлена безопасная обработка крупных изображений. - Ошибки декодирования теперь показываются пользователю без замены текущей подложки. - Обновлены smoke-тесты, документация, PyInstaller-конфигурация и версия приложения до 3.4.0.
148 lines
6.1 KiB
Python
148 lines
6.1 KiB
Python
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()
|