Compare commits

...

2 Commits

Author SHA1 Message Date
950aa6bee9 Async loading and use of temp file
Some checks failed
Python CI / test (push) Successful in 1m21s
Lint / lint (push) Successful in 1m48s
Tests / test (3.10) (push) Failing after 2m16s
Tests / test (3.11) (push) Failing after 1m30s
Tests / test (3.9) (push) Failing after 2m13s
2025-11-21 21:13:37 +01:00
1b0e6f9ed1 fixed bug with images being displayed squashed when dimensions exceeded render size 2025-11-11 17:13:12 +01:00
8 changed files with 761 additions and 141 deletions

View File

@ -354,12 +354,16 @@ class AsyncImageLoader(QObject):
if img.mode != 'RGBA':
img = img.convert('RGBA')
# Downsample if target size specified
# Downsample if target size specified (preserving aspect ratio)
if target_size:
current_size = img.size
if current_size[0] > target_size[0] or current_size[1] > target_size[1]:
img = img.resize(target_size, Image.Resampling.LANCZOS)
logger.debug(f"Downsampled {path}: {current_size} -> {target_size}")
max_size = target_size[0] # Assume square target (2048, 2048)
if img.width > max_size or img.height > max_size:
# Calculate scale to fit within max_size while preserving aspect ratio
scale = min(max_size / img.width, max_size / img.height)
new_width = int(img.width * scale)
new_height = int(img.height * scale)
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
logger.debug(f"Downsampled {path}: {img.size} -> ({new_width}, {new_height})")
return img

View File

@ -0,0 +1,251 @@
"""
Async project loader for pyPhotoAlbum
Loads projects asynchronously with progress updates to prevent UI freezing.
"""
import os
import json
import zipfile
import tempfile
from typing import Optional, Tuple
from pathlib import Path
from PyQt6.QtCore import QThread, pyqtSignal
from pyPhotoAlbum.project import Project
from pyPhotoAlbum.models import ImageData, set_asset_resolution_context
from pyPhotoAlbum.version_manager import (
CURRENT_DATA_VERSION,
check_version_compatibility,
VersionCompatibility,
DataMigration
)
class AsyncProjectLoader(QThread):
"""
Async worker thread for loading projects from ZIP files.
Signals:
progress_updated(int, int, str): Emitted with (current, total, message)
load_complete(Project): Emitted when loading succeeds
load_failed(str): Emitted when loading fails with error message
"""
progress_updated = pyqtSignal(int, int, str) # current, total, message
load_complete = pyqtSignal(object) # Project instance
load_failed = pyqtSignal(str) # error message
def __init__(self, zip_path: str, extract_to: Optional[str] = None):
super().__init__()
self.zip_path = zip_path
self.extract_to = extract_to
self._cancelled = False
def cancel(self):
"""Cancel the loading operation"""
self._cancelled = True
def run(self):
"""Run the async loading operation"""
try:
if not os.path.exists(self.zip_path):
self.load_failed.emit(f"ZIP file not found: {self.zip_path}")
return
if self._cancelled:
return
# Progress: Starting
self.progress_updated.emit(0, 100, "Preparing to load...")
# Track if we created a temp directory
temp_dir_obj = None
# Determine extraction directory
if self.extract_to is None:
zip_basename = os.path.splitext(os.path.basename(self.zip_path))[0]
temp_dir_obj = tempfile.TemporaryDirectory(prefix=f"pyPhotoAlbum_{zip_basename}_")
extract_to = temp_dir_obj.name
else:
os.makedirs(self.extract_to, exist_ok=True)
extract_to = self.extract_to
if self._cancelled:
return
# Progress: Extracting ZIP
self.progress_updated.emit(10, 100, "Extracting project files...")
# Extract ZIP contents with progress
with zipfile.ZipFile(self.zip_path, 'r') as zipf:
file_list = zipf.namelist()
total_files = len(file_list)
for i, filename in enumerate(file_list):
if self._cancelled:
return
zipf.extract(filename, extract_to)
# Update progress every 10 files or on last file
if i % 10 == 0 or i == total_files - 1:
progress = 10 + int((i / total_files) * 30) # 10-40%
self.progress_updated.emit(
progress, 100,
f"Extracting files... ({i + 1}/{total_files})"
)
if self._cancelled:
return
# Progress: Loading project data
self.progress_updated.emit(45, 100, "Loading project data...")
# Load project.json
project_json_path = os.path.join(extract_to, 'project.json')
if not os.path.exists(project_json_path):
self.load_failed.emit("Invalid project file: project.json not found")
return
with open(project_json_path, 'r') as f:
project_data = json.load(f)
if self._cancelled:
return
# Progress: Checking version
self.progress_updated.emit(55, 100, "Checking version compatibility...")
# Check version compatibility
file_version = project_data.get('data_version', project_data.get('serialization_version', '1.0'))
is_compatible, error_msg = check_version_compatibility(file_version, self.zip_path)
if not is_compatible:
self.load_failed.emit(error_msg)
return
# Apply migrations if needed
if VersionCompatibility.needs_migration(file_version):
self.progress_updated.emit(60, 100, f"Migrating from version {file_version}...")
try:
project_data = DataMigration.migrate(project_data, file_version, CURRENT_DATA_VERSION)
except Exception as e:
self.load_failed.emit(f"Migration failed: {str(e)}")
return
if self._cancelled:
return
# Progress: Creating project
self.progress_updated.emit(70, 100, "Creating project...")
# Create new project
project_name = project_data.get('name', 'Untitled Project')
project = Project(name=project_name, folder_path=extract_to)
# Deserialize project data
project.deserialize(project_data)
# Update folder path to extraction location
project.folder_path = extract_to
project.asset_manager.project_folder = extract_to
project.asset_manager.assets_folder = os.path.join(extract_to, "assets")
# Attach temporary directory to project (if we created one)
if temp_dir_obj is not None:
project._temp_dir = temp_dir_obj
if self._cancelled:
return
# Progress: Normalizing paths
self.progress_updated.emit(85, 100, "Normalizing asset paths...")
# Normalize asset paths
self._normalize_asset_paths(project, extract_to)
# Progress: Setting up asset resolution
self.progress_updated.emit(95, 100, "Setting up asset resolution...")
# Set asset resolution context
zip_directory = os.path.dirname(os.path.abspath(self.zip_path))
set_asset_resolution_context(extract_to, additional_search_paths=[zip_directory])
if self._cancelled:
return
# Progress: Complete
self.progress_updated.emit(100, 100, "Loading complete!")
# Emit success
self.load_complete.emit(project)
except Exception as e:
error_msg = f"Error loading project: {str(e)}"
self.load_failed.emit(error_msg)
def _normalize_asset_paths(self, project: Project, project_folder: str):
"""
Normalize asset paths in a loaded project to be relative to the project folder.
"""
normalized_count = 0
for page in project.pages:
for element in page.layout.elements:
if isinstance(element, ImageData) and element.image_path:
original_path = element.image_path
# Skip if already a simple relative path
if not os.path.isabs(original_path) and not original_path.startswith('./projects/'):
continue
# Pattern 1: "./projects/XXX/assets/filename.jpg" -> "assets/filename.jpg"
if '/assets/' in original_path:
parts = original_path.split('/assets/')
if len(parts) == 2:
new_path = os.path.join('assets', parts[1])
element.image_path = new_path
normalized_count += 1
continue
# Pattern 2: Absolute path - try to make it relative
if os.path.isabs(original_path):
try:
new_path = os.path.relpath(original_path, project_folder)
element.image_path = new_path
normalized_count += 1
except ValueError:
pass
if normalized_count > 0:
print(f"Normalized {normalized_count} asset paths")
def load_from_zip_async(zip_path: str, extract_to: Optional[str] = None,
progress_callback=None, complete_callback=None,
error_callback=None) -> AsyncProjectLoader:
"""
Load a project from a ZIP file asynchronously.
Args:
zip_path: Path to the ZIP file to load
extract_to: Optional directory to extract to. If None, uses a temporary directory.
progress_callback: Optional callback(current, total, message) for progress updates
complete_callback: Optional callback(project) when loading completes
error_callback: Optional callback(error_msg) when loading fails
Returns:
AsyncProjectLoader instance (already started)
"""
loader = AsyncProjectLoader(zip_path, extract_to)
if progress_callback:
loader.progress_updated.connect(progress_callback)
if complete_callback:
loader.load_complete.connect(complete_callback)
if error_callback:
loader.load_failed.connect(error_callback)
loader.start()
return loader

View File

@ -0,0 +1,184 @@
"""
Loading progress widget for pyPhotoAlbum
Displays loading progress in the lower-right corner of the window.
"""
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QProgressBar
from PyQt6.QtCore import Qt, QPropertyAnimation, QEasingCurve, pyqtProperty
from PyQt6.QtGui import QPalette, QColor
class LoadingWidget(QWidget):
"""
A widget that displays loading progress in the lower-right corner.
Features:
- Fade in/out animations
- Progress bar with percentage
- Status message display
- Compact, non-intrusive design
"""
def __init__(self, parent=None):
super().__init__(parent)
# Widget configuration
self.setWindowFlags(Qt.WindowType.ToolTip | Qt.WindowType.FramelessWindowHint)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, False)
self.setFixedSize(280, 80)
# Styling
self.setStyleSheet("""
QWidget {
background-color: rgba(50, 50, 50, 230);
border-radius: 8px;
border: 1px solid rgba(100, 100, 100, 180);
}
QLabel {
color: white;
background-color: transparent;
font-size: 11pt;
}
QProgressBar {
border: 1px solid rgba(80, 80, 80, 180);
border-radius: 4px;
background-color: rgba(30, 30, 30, 200);
text-align: center;
color: white;
font-size: 10pt;
}
QProgressBar::chunk {
background-color: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 rgba(70, 130, 180, 220),
stop:1 rgba(100, 160, 210, 220));
border-radius: 3px;
}
""")
# Layout
layout = QVBoxLayout()
layout.setContentsMargins(12, 10, 12, 10)
layout.setSpacing(8)
# Status label
self._status_label = QLabel("Loading...")
self._status_label.setAlignment(Qt.AlignmentFlag.AlignLeft)
layout.addWidget(self._status_label)
# Progress bar with percentage label
progress_layout = QHBoxLayout()
progress_layout.setSpacing(8)
self._progress_bar = QProgressBar()
self._progress_bar.setMinimum(0)
self._progress_bar.setMaximum(100)
self._progress_bar.setValue(0)
self._progress_bar.setTextVisible(True)
self._progress_bar.setFormat("%p%")
progress_layout.addWidget(self._progress_bar, 1)
layout.addLayout(progress_layout)
self.setLayout(layout)
# Animation for fade in/out
self._opacity = 1.0
self._fade_animation = QPropertyAnimation(self, b"opacity")
self._fade_animation.setDuration(300)
self._fade_animation.setEasingCurve(QEasingCurve.Type.InOutQuad)
# Initially hidden
self.hide()
@pyqtProperty(float)
def opacity(self):
"""Get opacity for animation"""
return self._opacity
@opacity.setter
def opacity(self, value):
"""Set opacity for animation"""
self._opacity = value
self.setWindowOpacity(value)
def show_loading(self, message: str = "Loading..."):
"""
Show the loading widget with a fade-in animation.
Args:
message: Initial status message
"""
self.set_status(message)
self.set_progress(0)
# Position in lower-right corner of parent
self._reposition()
# Fade in
self.show()
self._fade_animation.stop()
self._fade_animation.setStartValue(0.0)
self._fade_animation.setEndValue(1.0)
self._fade_animation.start()
def hide_loading(self):
"""Hide the loading widget with a fade-out animation."""
self._fade_animation.stop()
self._fade_animation.setStartValue(1.0)
self._fade_animation.setEndValue(0.0)
self._fade_animation.finished.connect(self.hide)
self._fade_animation.start()
def set_status(self, message: str):
"""
Update the status message.
Args:
message: Status message to display
"""
self._status_label.setText(message)
def set_progress(self, value: int, maximum: int = 100):
"""
Update the progress bar.
Args:
value: Current progress value
maximum: Maximum progress value (default: 100)
"""
self._progress_bar.setMaximum(maximum)
self._progress_bar.setValue(value)
def set_indeterminate(self, indeterminate: bool = True):
"""
Set the progress bar to indeterminate mode (busy indicator).
Args:
indeterminate: True for indeterminate, False for normal progress
"""
if indeterminate:
self._progress_bar.setMinimum(0)
self._progress_bar.setMaximum(0)
else:
self._progress_bar.setMinimum(0)
self._progress_bar.setMaximum(100)
def _reposition(self):
"""Position the widget in the lower-right corner of the parent."""
if self.parent():
parent_rect = self.parent().rect()
margin = 20
x = parent_rect.width() - self.width() - margin
y = parent_rect.height() - self.height() - margin
self.move(x, y)
def showEvent(self, event):
"""Handle show event to reposition."""
super().showEvent(event)
self._reposition()
def resizeParent(self):
"""Call this when parent is resized to reposition the widget."""
if self.isVisible():
self._reposition()

View File

@ -147,6 +147,12 @@ class MainWindow(
redo_shortcut.activated.connect(self.redo)
print("Registered shortcut: Ctrl+Shift+Z -> redo")
def resizeEvent(self, event):
"""Handle window resize to reposition loading widget"""
super().resizeEvent(event)
if hasattr(self, '_loading_widget'):
self._loading_widget.resizeParent()
def _add_sample_page(self):
"""Add a sample page with some elements for demonstration"""
from pyPhotoAlbum.project import Page

View File

@ -2,9 +2,19 @@
File operations mixin for pyPhotoAlbum
"""
from PyQt6.QtWidgets import QFileDialog, QDialog, QVBoxLayout, QHBoxLayout, QLabel, QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox, QRadioButton, QButtonGroup
from PyQt6.QtWidgets import (
QFileDialog, QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox, QRadioButton,
QButtonGroup, QLineEdit, QTextEdit
)
from pyPhotoAlbum.decorators import ribbon_action, numerical_input
from pyPhotoAlbum.project import Project
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.async_project_loader import AsyncProjectLoader
from pyPhotoAlbum.loading_widget import LoadingWidget
from pyPhotoAlbum.project_serializer import save_to_zip
from pyPhotoAlbum.models import set_asset_resolution_context
from pyPhotoAlbum.version_manager import format_version_info, CURRENT_DATA_VERSION
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
class FileOperationsMixin:
@ -19,8 +29,6 @@ class FileOperationsMixin:
)
def new_project(self):
"""Create a new project with initial setup dialog"""
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox, QLineEdit
# Create new project setup dialog
dialog = QDialog(self)
dialog.setWindowTitle("New Project Setup")
@ -153,7 +161,6 @@ class FileOperationsMixin:
self.project.export_dpi = export_dpi
# Set asset resolution context
from pyPhotoAlbum.models import set_asset_resolution_context
set_asset_resolution_context(self.project.folder_path)
# Update view
@ -173,9 +180,7 @@ class FileOperationsMixin:
shortcut="Ctrl+O"
)
def open_project(self):
"""Open an existing project"""
from pyPhotoAlbum.project_serializer import load_from_zip
"""Open an existing project with async loading and progress bar"""
file_path, _ = QFileDialog.getOpenFileName(
self,
"Open Project",
@ -186,23 +191,60 @@ class FileOperationsMixin:
if file_path:
print(f"Opening project: {file_path}")
# Cleanup old project if it exists
if hasattr(self, 'project') and self.project:
self.project.cleanup()
# Create loading widget if not exists
if not hasattr(self, '_loading_widget'):
self._loading_widget = LoadingWidget(self)
# Load project from ZIP
project, error = load_from_zip(file_path)
# Show loading widget
self._loading_widget.show_loading("Opening project...")
if project:
self.project = project
self.current_page_index = 0 # Reset to first page
self.update_view()
self.show_status(f"Project opened: {file_path}")
print(f"Successfully loaded project: {project.name}")
else:
error_msg = f"Failed to open project: {error}"
self.show_status(error_msg)
print(error_msg)
# Create and configure async loader
self._project_loader = AsyncProjectLoader(file_path)
# Connect signals
self._project_loader.progress_updated.connect(self._on_load_progress)
self._project_loader.load_complete.connect(self._on_load_complete)
self._project_loader.load_failed.connect(self._on_load_failed)
# Start async loading
self._project_loader.start()
def _on_load_progress(self, current: int, total: int, message: str):
"""Handle loading progress updates"""
if hasattr(self, '_loading_widget'):
self._loading_widget.set_progress(current, total)
self._loading_widget.set_status(message)
def _on_load_complete(self, project):
"""Handle successful project load"""
# Cleanup old project if it exists
if hasattr(self, 'project') and self.project:
self.project.cleanup()
# Set new project
self.project = project
self.gl_widget.current_page_index = 0 # Reset to first page
# Hide loading widget
if hasattr(self, '_loading_widget'):
self._loading_widget.hide_loading()
# Update view (this will trigger progressive image loading)
self.update_view()
self.show_status(f"Project opened: {project.name}")
print(f"Successfully loaded project: {project.name}")
def _on_load_failed(self, error_msg: str):
"""Handle project load failure"""
# Hide loading widget
if hasattr(self, '_loading_widget'):
self._loading_widget.hide_loading()
error_msg = f"Failed to open project: {error_msg}"
self.show_status(error_msg)
self.show_error("Load Failed", error_msg)
print(error_msg)
@ribbon_action(
label="Save",
@ -213,8 +255,6 @@ class FileOperationsMixin:
)
def save_project(self):
"""Save the current project"""
from pyPhotoAlbum.project_serializer import save_to_zip
file_path, _ = QFileDialog.getSaveFileName(
self,
"Save Project",
@ -244,8 +284,6 @@ class FileOperationsMixin:
)
def heal_assets(self):
"""Open the asset healing dialog to reconnect missing images"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
dialog = AssetHealDialog(self.project, self)
dialog.exec()
@ -529,9 +567,6 @@ class FileOperationsMixin:
)
def show_about(self):
"""Show about dialog with version information"""
from PyQt6.QtWidgets import QTextEdit
from pyPhotoAlbum.version_manager import format_version_info, CURRENT_DATA_VERSION
dialog = QDialog(self)
dialog.setWindowTitle("About pyPhotoAlbum")
dialog.setMinimumWidth(600)

View File

@ -6,6 +6,7 @@ from abc import ABC, abstractmethod
from typing import Tuple, Optional, Dict, Any, List
import json
import os
from PIL import Image
# Global configuration for asset path resolution
_asset_search_paths: List[str] = []
@ -57,15 +58,55 @@ class BaseLayoutElement(ABC):
class ImageData(BaseLayoutElement):
"""Class to store image data and properties"""
def __init__(self, image_path: str = "", crop_info: Optional[Tuple] = None, **kwargs):
def __init__(self, image_path: str = "", crop_info: Optional[Tuple] = None,
image_dimensions: Optional[Tuple[int, int]] = None, **kwargs):
super().__init__(**kwargs)
self.image_path = image_path
self.crop_info = crop_info or (0, 0, 1, 1) # Default: no crop
# Metadata: Store image dimensions for aspect ratio calculations before full load
# This allows correct rendering even while async loading is in progress
self.image_dimensions = image_dimensions # (width, height) or None
# If dimensions not provided and we have a path, try to extract them quickly
if not self.image_dimensions and self.image_path:
self._extract_dimensions_metadata()
# Async loading state
self._async_loading = False
self._async_load_requested = False
def _extract_dimensions_metadata(self):
"""
Extract image dimensions without loading the full image.
Uses PIL's lazy loading to just read the header.
"""
try:
# Resolve path
image_path = self.image_path
if not os.path.isabs(self.image_path):
project_folder, search_paths = get_asset_search_paths()
if project_folder and os.path.exists(os.path.join(project_folder, self.image_path)):
image_path = os.path.join(project_folder, self.image_path)
if os.path.exists(image_path):
# Use PIL to just read dimensions (fast, doesn't load pixel data)
with Image.open(image_path) as img:
width, height = img.width, img.height
# Apply same downsampling logic as the old sync code (max 2048px)
max_size = 2048
if width > max_size or height > max_size:
scale = min(max_size / width, max_size / height)
width = int(width * scale)
height = int(height * scale)
self.image_dimensions = (width, height)
print(f"ImageData: Extracted dimensions {self.image_dimensions} for {self.image_path}")
except Exception as e:
print(f"ImageData: Could not extract dimensions for {self.image_path}: {e}")
self.image_dimensions = None
def render(self):
"""Render the image using OpenGL"""
from OpenGL.GL import (glBegin, glEnd, glVertex2f, glColor3f, glColor4f, GL_QUADS, GL_LINE_LOOP,
@ -126,97 +167,71 @@ class ImageData(BaseLayoutElement):
print(f"ImageData: Could not resolve path: {self.image_path}")
print(f" Tried paths: {possible_paths[:3]}") # Print first 3 to avoid clutter
if image_full_path and os.path.exists(image_full_path):
# Check if we already have a cached texture
if not hasattr(self, '_texture_id') or not hasattr(self, '_texture_path') or self._texture_path != image_full_path:
try:
# Load image
img = Image.open(image_full_path)
img = img.convert('RGBA')
# NOTE: Async loading is now handled by page_layout.py calling request_image_load()
# This sync path should only be reached if async loading is not available
# The actual image will be loaded in the background and the texture created
# via _on_async_image_loaded() callback when ready
# Scale down large images for performance (max 2048px on longest side)
max_size = 2048
if img.width > max_size or img.height > max_size:
scale = min(max_size / img.width, max_size / img.height)
new_width = int(img.width * scale)
new_height = int(img.height * scale)
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
# Use cached texture if available
if hasattr(self, '_texture_id') and self._texture_id:
texture_id = self._texture_id
img_data = img.tobytes()
# Get image dimensions (from loaded texture or metadata)
if hasattr(self, '_img_width') and hasattr(self, '_img_height'):
img_width, img_height = self._img_width, self._img_height
elif self.image_dimensions:
img_width, img_height = self.image_dimensions
else:
# No dimensions available, render without aspect ratio correction
img_width, img_height = int(w), int(h)
# Delete old texture if it exists
if hasattr(self, '_texture_id') and self._texture_id:
glDeleteTextures([self._texture_id])
# Get crop info
crop_x_min, crop_y_min, crop_x_max, crop_y_max = self.crop_info
# Create texture
texture_id = glGenTextures(1)
glBindTexture(GL_TEXTURE_2D, texture_id)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, img.width, img.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, img_data)
# Calculate aspect ratios for center crop
img_aspect = img_width / img_height
target_aspect = w / h
# Cache texture ID and path
self._texture_id = texture_id
self._texture_path = image_full_path
self._img_width = img.width
self._img_height = img.height
# Calculate texture coordinates for center crop
if img_aspect > target_aspect:
# Image is wider - crop horizontally
scale = target_aspect / img_aspect
tx_offset = (1.0 - scale) / 2.0
tx_min_base = tx_offset
tx_max_base = 1.0 - tx_offset
ty_min_base = 0.0
ty_max_base = 1.0
else:
# Image is taller - crop vertically
scale = img_aspect / target_aspect
ty_offset = (1.0 - scale) / 2.0
tx_min_base = 0.0
tx_max_base = 1.0
ty_min_base = ty_offset
ty_max_base = 1.0 - ty_offset
except Exception as e:
print(f"Error loading image {image_full_path}: {e}")
print(f" Original path: {self.image_path}")
self._texture_id = None
# Apply additional crop from crop_info (for spanning elements)
tx_range = tx_max_base - tx_min_base
ty_range = ty_max_base - ty_min_base
# Use cached texture
if hasattr(self, '_texture_id') and self._texture_id:
texture_id = self._texture_id
tx_min = tx_min_base + crop_x_min * tx_range
tx_max = tx_min_base + crop_x_max * tx_range
ty_min = ty_min_base + crop_y_min * ty_range
ty_max = ty_min_base + crop_y_max * ty_range
# Get crop info
crop_x_min, crop_y_min, crop_x_max, crop_y_max = self.crop_info
# Enable texturing and draw with crop
glEnable(GL_TEXTURE_2D)
glBindTexture(GL_TEXTURE_2D, texture_id)
glColor4f(1.0, 1.0, 1.0, 1.0) # White color to show texture as-is
# Calculate aspect ratios for center crop
img_aspect = self._img_width / self._img_height
target_aspect = w / h
glBegin(GL_QUADS)
glTexCoord2f(tx_min, ty_min); glVertex2f(x, y)
glTexCoord2f(tx_max, ty_min); glVertex2f(x + w, y)
glTexCoord2f(tx_max, ty_max); glVertex2f(x + w, y + h)
glTexCoord2f(tx_min, ty_max); glVertex2f(x, y + h)
glEnd()
# Calculate texture coordinates for center crop
if img_aspect > target_aspect:
# Image is wider - crop horizontally
scale = target_aspect / img_aspect
tx_offset = (1.0 - scale) / 2.0
tx_min_base = tx_offset
tx_max_base = 1.0 - tx_offset
ty_min_base = 0.0
ty_max_base = 1.0
else:
# Image is taller - crop vertically
scale = img_aspect / target_aspect
ty_offset = (1.0 - scale) / 2.0
tx_min_base = 0.0
tx_max_base = 1.0
ty_min_base = ty_offset
ty_max_base = 1.0 - ty_offset
# Apply additional crop from crop_info (for spanning elements)
tx_range = tx_max_base - tx_min_base
ty_range = ty_max_base - ty_min_base
tx_min = tx_min_base + crop_x_min * tx_range
tx_max = tx_min_base + crop_x_max * tx_range
ty_min = ty_min_base + crop_y_min * ty_range
ty_max = ty_min_base + crop_y_max * ty_range
# Enable texturing and draw with crop
glEnable(GL_TEXTURE_2D)
glBindTexture(GL_TEXTURE_2D, texture_id)
glColor4f(1.0, 1.0, 1.0, 1.0) # White color to show texture as-is
glBegin(GL_QUADS)
glTexCoord2f(tx_min, ty_min); glVertex2f(x, y)
glTexCoord2f(tx_max, ty_min); glVertex2f(x + w, y)
glTexCoord2f(tx_max, ty_max); glVertex2f(x + w, y + h)
glTexCoord2f(tx_min, ty_max); glVertex2f(x, y + h)
glEnd()
glDisable(GL_TEXTURE_2D)
glDisable(GL_TEXTURE_2D)
# If no image or loading failed, draw placeholder
if not texture_id:
@ -243,7 +258,7 @@ class ImageData(BaseLayoutElement):
def serialize(self) -> Dict[str, Any]:
"""Serialize image data to dictionary"""
return {
data = {
"type": "image",
"position": self.position,
"size": self.size,
@ -252,6 +267,10 @@ class ImageData(BaseLayoutElement):
"image_path": self.image_path,
"crop_info": self.crop_info
}
# Include image dimensions metadata if available
if self.image_dimensions:
data["image_dimensions"] = self.image_dimensions
return data
def deserialize(self, data: Dict[str, Any]):
"""Deserialize from dictionary"""
@ -261,6 +280,10 @@ class ImageData(BaseLayoutElement):
self.z_index = data.get("z_index", 0)
self.image_path = data.get("image_path", "")
self.crop_info = tuple(data.get("crop_info", (0, 0, 1, 1)))
# Load image dimensions metadata if available
self.image_dimensions = data.get("image_dimensions", None)
if self.image_dimensions:
self.image_dimensions = tuple(self.image_dimensions)
def _on_async_image_loaded(self, pil_image):
"""
@ -295,6 +318,10 @@ class ImageData(BaseLayoutElement):
self._img_height = pil_image.height
self._async_loading = False
# Update metadata for future renders
if not self.image_dimensions:
self.image_dimensions = (pil_image.width, pil_image.height)
print(f"ImageData: Async loaded texture for {self.image_path}")
except Exception as e:

View File

@ -494,11 +494,8 @@ class PDFExporter:
cropped_img = cropped_img.resize((target_width_px, target_height_px),
Image.Resampling.LANCZOS)
# Apply rotation if needed
if image_element.rotation != 0:
# Rotate around center
cropped_img = cropped_img.rotate(-image_element.rotation, expand=True,
fillcolor=(255, 255, 255, 0))
# Note: Rotation is applied at the canvas level (below), not here
# to avoid double-rotation issues
# Save state for transformations
c.saveState()
@ -509,7 +506,7 @@ class PDFExporter:
center_x = x_pt + width_pt / 2
center_y = y_pt + height_pt / 2
c.translate(center_x, center_y)
c.rotate(image_element.rotation)
c.rotate(-image_element.rotation)
c.translate(-width_pt / 2, -height_pt / 2)
# Draw at origin after transformation
c.drawImage(ImageReader(cropped_img), 0, 0, width_pt, height_pt,

116
test_loading_widget.py Normal file
View File

@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""
Test script for the loading widget functionality
"""
import sys
import time
from pathlib import Path
from PyQt6.QtWidgets import QApplication, QMainWindow, QPushButton, QVBoxLayout, QWidget
from PyQt6.QtCore import QTimer
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent))
from pyPhotoAlbum.loading_widget import LoadingWidget
class TestWindow(QMainWindow):
"""Test window for loading widget"""
def __init__(self):
super().__init__()
self.setWindowTitle("Loading Widget Test")
self.resize(800, 600)
# Central widget
central = QWidget()
layout = QVBoxLayout()
# Test buttons
btn1 = QPushButton("Show Loading (Determinate)")
btn1.clicked.connect(self.test_determinate)
layout.addWidget(btn1)
btn2 = QPushButton("Show Loading (Indeterminate)")
btn2.clicked.connect(self.test_indeterminate)
layout.addWidget(btn2)
btn3 = QPushButton("Simulate File Loading")
btn3.clicked.connect(self.simulate_file_loading)
layout.addWidget(btn3)
central.setLayout(layout)
self.setCentralWidget(central)
# Create loading widget
self.loading_widget = LoadingWidget(self)
# Timer for progress simulation
self.timer = QTimer()
self.timer.timeout.connect(self.update_progress)
self.progress = 0
def test_determinate(self):
"""Test determinate progress"""
self.loading_widget.show_loading("Loading...")
self.loading_widget.set_indeterminate(False)
self.loading_widget.set_progress(50, 100)
# Auto hide after 3 seconds
QTimer.singleShot(3000, self.loading_widget.hide_loading)
def test_indeterminate(self):
"""Test indeterminate progress"""
self.loading_widget.show_loading("Processing...")
self.loading_widget.set_indeterminate(True)
# Auto hide after 3 seconds
QTimer.singleShot(3000, self.loading_widget.hide_loading)
def simulate_file_loading(self):
"""Simulate file loading with progress"""
self.progress = 0
self.loading_widget.show_loading("Extracting files...")
self.loading_widget.set_indeterminate(False)
self.timer.start(100) # Update every 100ms
def update_progress(self):
"""Update progress during simulation"""
self.progress += 5
if self.progress <= 40:
self.loading_widget.set_status(f"Extracting files... ({self.progress}%)")
elif self.progress <= 70:
self.loading_widget.set_status("Loading project data...")
elif self.progress <= 95:
self.loading_widget.set_status("Normalizing asset paths...")
else:
self.loading_widget.set_status("Loading complete!")
self.loading_widget.set_progress(self.progress, 100)
if self.progress >= 100:
self.timer.stop()
QTimer.singleShot(500, self.loading_widget.hide_loading)
def resizeEvent(self, event):
"""Handle resize"""
super().resizeEvent(event)
self.loading_widget.resizeParent()
def main():
"""Run test"""
app = QApplication(sys.argv)
window = TestWindow()
window.show()
print("Loading widget test window opened")
print("Click buttons to test different loading states")
sys.exit(app.exec())
if __name__ == "__main__":
main()