More CI fixes and mypy fixes
Some checks failed
Lint / lint (push) Successful in 20s
Tests / test (push) Successful in 10s
Python CI / test (push) Failing after 1m43s

This commit is contained in:
Duncan Tourolle 2026-04-09 23:07:59 +02:00
parent fd0758dd6c
commit 1fe44e7d8a
23 changed files with 154 additions and 99 deletions

View File

@ -14,24 +14,13 @@ jobs:
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/pyphotoalbum-ci:latest
strategy:
matrix:
python-version: ['3.11', '3.12', '3.13', '3.14']
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
pip install setuptools coverage-badge interrogate
- name: Install project
run: pip3 install -e . --no-deps --break-system-packages
- name: Download initial failed badges
run: |
@ -71,7 +60,7 @@ jobs:
- name: Generate coverage reports
if: steps.pytest.outcome == 'success'
run: |
python -c "
python3 -c "
import json
import os
if os.path.exists('coverage.json'):
@ -100,15 +89,8 @@ jobs:
name: coverage-reports
path: cov_info/
- name: Upload coverage reports to Codecov
if: matrix.python-version == '3.11'
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
fail_ci_if_error: false
- name: Commit badges to badges branch
if: github.ref == 'refs/heads/master' && matrix.python-version == '3.11'
if: github.ref == 'refs/heads/master'
run: |
git config --local user.email "action@gitea.local"
git config --local user.name "Gitea Action"

View File

@ -6,9 +6,9 @@ FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y \
--no-install-recommends \
# Python
python3 \
python3-venv \
python3-pip \
# PyQt6 / OpenGL runtime deps
libgl1 \
@ -42,3 +42,24 @@ RUN apt-get update && apt-get install -y \
git \
nodejs \
&& rm -rf /var/lib/apt/lists/*
# Install all Python dependencies so CI runs don't need to download anything
RUN pip3 install --break-system-packages --no-cache-dir \
# Runtime deps
PyQt6 \
PyOpenGL \
numpy \
Pillow \
reportlab \
lxml \
pypdf \
# Dev/test deps
pytest \
pytest-qt \
pytest-cov \
pytest-mock \
pdfplumber \
flake8 \
coverage-badge \
interrogate \
setuptools

View File

@ -345,7 +345,7 @@ class AssetManager:
Returns:
List of relative paths to unused assets
"""
unused = []
unused: list[str] = []
if not os.path.exists(self.assets_folder):
return unused

View File

@ -405,7 +405,7 @@ class AsyncImageLoader(QObject):
Returns:
Processed PIL Image
"""
img = Image.open(path)
img: Image.Image = Image.open(path)
img = convert_to_rgba(img)
# Downsample if target size specified (preserving aspect ratio)
@ -485,6 +485,7 @@ class AsyncImageLoader(QObject):
self._pending_requests[path] = request
# Submit to queue (thread-safe)
assert self._queue is not None
asyncio.run_coroutine_threadsafe(self._queue.put(request), self._loop)
logger.debug(f"Queued load: {path} (priority: {priority.name})")
@ -768,8 +769,8 @@ class AsyncPDFGenerator(QObject):
# Temporarily patch Image.open
try:
Image.open = cached_open
return exporter.export(output_path, progress_callback)
Image.open = cached_open # type: ignore[assignment]
return exporter.export(output_path, progress_callback) # type: ignore[no-any-return]
finally:
# Restore original
Image.open = original_open

View File

@ -42,6 +42,7 @@ def _deserialize_element(elem_data: Dict[str, Any]) -> BaseLayoutElement:
"""
elem_type = elem_data.get("type")
element: BaseLayoutElement
if elem_type == "image":
element = ImageData()
elif elem_type == "placeholder":
@ -759,6 +760,8 @@ class CommandHistory:
def _deserialize_command(self, data: Dict[str, Any], project) -> Optional[Command]:
"""Deserialize a single command using registry pattern"""
cmd_type = data.get("type")
if cmd_type is None:
return None
deserializer = self._COMMAND_DESERIALIZERS.get(cmd_type)
if not deserializer:

View File

@ -310,7 +310,7 @@ class FrameManager:
# Load as PIL Image from bytes buffer
buffer = io.BytesIO(png_data)
img = Image.open(buffer)
img: Image.Image = Image.open(buffer)
if img.mode != "RGBA":
img = img.convert("RGBA")
@ -836,11 +836,11 @@ class FrameManager:
# Flip horizontally if h position differs
if asset_h != target_h:
img = img.transpose(Image.FLIP_LEFT_RIGHT)
img = img.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
# Flip vertically if v position differs
if asset_v != target_v:
img = img.transpose(Image.FLIP_TOP_BOTTOM)
img = img.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
return img

View File

@ -4,7 +4,7 @@ Async loading mixin for non-blocking image loading and PDF generation.
import os
from pathlib import Path
from typing import TYPE_CHECKING, Optional
from typing import TYPE_CHECKING, Optional, cast
import logging
from PyQt6.QtCore import QObject
@ -22,11 +22,11 @@ class AsyncLoadingMixin:
# Type hints for expected attributes from mixing class
_pdf_progress_dialog: Optional[QProgressDialog]
def update(self) -> None:
def update(self) -> None: # type: ignore[empty-body]
"""Expected from QWidget"""
...
def window(self) -> "QMainWindow":
def window(self) -> "QMainWindow": # type: ignore[empty-body]
"""Expected from QWidget"""
...
@ -224,7 +224,9 @@ class AsyncLoadingMixin:
total_pages = sum(1 if page.is_cover else (2 if page.is_double_spread else 1) for page in project.pages)
self._pdf_progress_dialog = QProgressDialog("Exporting to PDF...", "Cancel", 0, total_pages, self)
from PyQt6.QtWidgets import QWidget
self._pdf_progress_dialog = QProgressDialog("Exporting to PDF...", "Cancel", 0, total_pages, cast(QWidget, self))
self._pdf_progress_dialog.setWindowModality(Qt.WindowModality.WindowModal)
self._pdf_progress_dialog.setWindowTitle("PDF Export")
self._pdf_progress_dialog.canceled.connect(self._on_pdf_cancel)

View File

@ -15,7 +15,7 @@ class ElementManipulationMixin:
drag_start_pos: Optional[Tuple[float, float]]
drag_start_element_pos: Optional[Tuple[float, float]]
def window(self) -> "QMainWindow":
def window(self) -> "QMainWindow": # type: ignore[empty-body]
"""Expected from QWidget"""
...
@ -62,12 +62,12 @@ class ElementManipulationMixin:
self._resize_element_no_snap(dx, dy)
return
parent_page = self.selected_element._parent_page
parent_page = self.selected_element._parent_page # type: ignore[attr-defined]
snap_sys = parent_page.layout.snapping_system
# Get page size
page_size = parent_page.layout.size
dpi = main_window.project.working_dpi
dpi = main_window.project.working_dpi # type: ignore[attr-defined]
# Apply snapping to resize
from pyPhotoAlbum.snapping import SnapResizeParams
@ -80,7 +80,7 @@ class ElementManipulationMixin:
resize_handle=self.resize_handle,
page_size=page_size,
dpi=dpi,
project=main_window.project,
project=main_window.project, # type: ignore[attr-defined]
)
new_pos, new_size = snap_sys.snap_resize(params)
@ -106,6 +106,8 @@ class ElementManipulationMixin:
"""
if not self.resize_start_pos or not self.resize_start_size:
return
if self.selected_element is None:
return
start_x, start_y = self.resize_start_pos
start_w, start_h = self.resize_start_size

View File

@ -14,7 +14,7 @@ class ElementSelectionMixin:
# Type hints for expected attributes from mixing class
_page_renderers: list
def window(self) -> "QMainWindow":
def window(self) -> "QMainWindow": # type: ignore[empty-body]
"""Expected from QWidget"""
...
@ -86,7 +86,7 @@ class ElementSelectionMixin:
# Store the renderer with the element for later use
element._page_renderer = renderer # type: ignore[attr-defined]
element._parent_page = page # type: ignore[attr-defined]
return element
return element # type: ignore[no-any-return]
return None

View File

@ -123,7 +123,7 @@ class InteractionState:
Returns:
Dict with non-None state values
"""
state = {}
state: Dict[str, Any] = {}
if self.position is not None:
state["position"] = self.position
if self.size is not None:

View File

@ -3,9 +3,14 @@ Mouse interaction mixin for GLWidget - coordinates all mouse events
"""
import math
from typing import TYPE_CHECKING, Any, Optional, Set
from PyQt6.QtCore import Qt
from pyPhotoAlbum.models import ImageData
from PyQt6.QtGui import QCursor
from pyPhotoAlbum.models import ImageData, BaseLayoutElement
if TYPE_CHECKING:
from PyQt6.QtWidgets import QMainWindow
class MouseInteractionMixin:
@ -16,6 +21,39 @@ class MouseInteractionMixin:
the current interaction state.
"""
# Type declarations for attributes provided by other mixins/base classes
selected_elements: Set[BaseLayoutElement]
selected_element: Optional[BaseLayoutElement]
rotation_mode: bool
rotation_snap_angle: int
rotation_start_angle: Optional[float]
pan_offset: list
zoom_level: float
image_pan_mode: bool
current_page_index: int
_page_renderers: list
resize_start_pos: Optional[Any]
resize_start_size: Optional[Any]
if TYPE_CHECKING:
def window(self) -> "QMainWindow": ...
def update(self) -> None: ...
def setCursor(self, cursor: Any) -> None: ...
def setFocus(self, *args: Any) -> None: ...
def _begin_rotate(self, element: Any) -> None: ...
def _begin_resize(self, element: Any) -> None: ...
def _begin_image_pan(self, element: Any) -> None: ...
def _begin_move(self, element: Any) -> None: ...
def _end_interaction(self) -> None: ...
def _resize_element(self, dx: float, dy: float) -> None: ...
def _get_page_at(self, x: float, y: float) -> Any: ...
def _get_element_at(self, x: float, y: float) -> Optional[BaseLayoutElement]: ...
def _get_resize_handle_at(self, x: float, y: float) -> Optional[str]: ...
def _check_ghost_page_click(self, x: float, y: float) -> bool: ...
def _transfer_element_to_page(self, element: Any, source: Any, target: Any, x: float, y: float, renderer: Any) -> None: ...
def _handle_image_pan_move(self, x: float, y: float, element: Any) -> None: ...
def _update_page_status(self, x: float, y: float) -> None: ...
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@ -27,6 +65,7 @@ class MouseInteractionMixin:
def _handle_rotation_start(self, x: float, y: float):
"""Start rotation interaction for selected element."""
assert self.selected_element is not None
self._begin_rotate(self.selected_element)
self.drag_start_pos = (x, y)
self.rotation_start_angle = self.selected_element.rotation
@ -34,6 +73,7 @@ class MouseInteractionMixin:
def _handle_resize_start(self, x: float, y: float, handle):
"""Start resize interaction for selected element."""
assert self.selected_element is not None
self._begin_resize(self.selected_element)
self.resize_handle = handle
self.drag_start_pos = (x, y)
@ -139,10 +179,10 @@ class MouseInteractionMixin:
def _handle_rotation_move(self, x: float, y: float):
"""Handle element rotation during drag."""
if not hasattr(self.selected_element, "_page_renderer"):
if self.selected_element is None or not hasattr(self.selected_element, "_page_renderer"):
return
renderer = self.selected_element._page_renderer
renderer = self.selected_element._page_renderer # type: ignore[attr-defined]
elem_x, elem_y = self.selected_element.position
elem_w, elem_h = self.selected_element.size
@ -174,10 +214,11 @@ class MouseInteractionMixin:
def _handle_element_move(self, x: float, y: float):
"""Handle element movement during drag, including page transfer."""
assert self.selected_element is not None
current_page, current_page_index, current_renderer = self._get_page_at(x, y)
if current_page and hasattr(self.selected_element, "_parent_page"):
source_page = self.selected_element._parent_page
source_page = self.selected_element._parent_page # type: ignore[attr-defined]
if current_page is not source_page:
self._transfer_element_to_page(self.selected_element, source_page, current_page, x, y, current_renderer)
@ -201,17 +242,18 @@ class MouseInteractionMixin:
new_x = self.drag_start_element_pos[0] + total_dx
new_y = self.drag_start_element_pos[1] + total_dy
assert self.selected_element is not None
main_window = self.window()
snap_sys = page.layout.snapping_system
page_size = page.layout.size
dpi = main_window.project.working_dpi
dpi = main_window.project.working_dpi # type: ignore[attr-defined]
snapped_pos = snap_sys.snap_position(
position=(new_x, new_y),
size=self.selected_element.size,
page_size=page_size,
dpi=dpi,
project=main_window.project,
project=main_window.project, # type: ignore[attr-defined]
)
self.selected_element.position = snapped_pos

View File

@ -23,15 +23,15 @@ class AlignmentOperationsMixin:
status_msg: Status message format string (will receive element count)
"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2):
if not self.require_selection(min_count=2): # type: ignore[attr-defined]
return
changes = alignment_func(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(status_msg.format(len(elements)), 2000)
self.project.history.execute(cmd) # type: ignore[attr-defined]
self.update_view() # type: ignore[attr-defined]
self.show_status(status_msg.format(len(elements)), 2000) # type: ignore[attr-defined]
@ribbon_action(
label="Align Left",

View File

@ -23,15 +23,15 @@ class DistributionOperationsMixin:
status_msg: Status message format string (will receive element count)
"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=3):
if not self.require_selection(min_count=3): # type: ignore[attr-defined]
return
changes = distribution_func(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(status_msg.format(len(elements)), 2000)
self.project.history.execute(cmd) # type: ignore[attr-defined]
self.update_view() # type: ignore[attr-defined]
self.show_status(status_msg.format(len(elements)), 2000) # type: ignore[attr-defined]
@ribbon_action(
label="Distribute H",

View File

@ -301,7 +301,7 @@ class FileOperationsMixin:
if not file_path:
file_path, _ = QFileDialog.getSaveFileName(
self, "Save Project", "", "pyPhotoAlbum Projects (*.ppz);;All Files (*)"
self, "Save Project", "", "pyPhotoAlbum Projects (*.ppz);;All Files (*)" # type: ignore[arg-type]
)
if not file_path:
@ -319,7 +319,7 @@ class FileOperationsMixin:
# Bridge object: signals are thread-safe so background thread can
# emit them and slots always run on the main (GUI) thread.
bridge = _SaveBridge(parent=self)
bridge = _SaveBridge(parent=self) # type: ignore[arg-type]
def _on_progress(progress: int, message: str):
if hasattr(self, "_loading_widget"):
@ -344,7 +344,7 @@ class FileOperationsMixin:
print(f"Successfully saved project to: {file_path}")
if getattr(self, "_pending_close", False):
self._pending_close = False
self.close()
self.close() # type: ignore[attr-defined]
else:
self._pending_close = False
error_msg = f"Failed to save project: {error}"
@ -419,7 +419,9 @@ class FileOperationsMixin:
"then click 'Attempt Healing' to find and import them."
)
msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Open)
msg.button(QMessageBox.StandardButton.Open).setText("Open Heal Assets")
btn = msg.button(QMessageBox.StandardButton.Open)
if btn is not None:
btn.setText("Open Heal Assets")
result = msg.exec()
if result == QMessageBox.StandardButton.Open:

View File

@ -23,15 +23,15 @@ class SizeOperationsMixin:
status_msg: Status message format string (will receive element count)
"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2):
if not self.require_selection(min_count=2): # type: ignore[attr-defined]
return
changes = resize_func(elements)
if changes:
cmd = ResizeElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(status_msg.format(len(elements)), 2000)
self.project.history.execute(cmd) # type: ignore[attr-defined]
self.update_view() # type: ignore[attr-defined]
self.show_status(status_msg.format(len(elements)), 2000) # type: ignore[attr-defined]
def _execute_fit_to_page(self, fit_func, status_msg: str):
"""
@ -41,22 +41,22 @@ class SizeOperationsMixin:
fit_func: Function that takes (element, page) and returns a change tuple
status_msg: Status message to display on success
"""
if not self.require_selection(min_count=1):
if not self.require_selection(min_count=1): # type: ignore[attr-defined]
return
page = self.get_current_page()
page = self.get_current_page() # type: ignore[attr-defined]
if not page:
self.show_warning("No Page", "Please create a page first.")
self.show_warning("No Page", "Please create a page first.") # type: ignore[attr-defined]
return
element = next(iter(self.gl_widget.selected_elements))
element = next(iter(self.gl_widget.selected_elements)) # type: ignore[attr-defined]
change = fit_func(element, page)
if change:
cmd = ResizeElementsCommand([change])
self.project.history.execute(cmd)
self.update_view()
self.show_status(status_msg, 2000)
self.project.history.execute(cmd) # type: ignore[attr-defined]
self.update_view() # type: ignore[attr-defined]
self.show_status(status_msg, 2000) # type: ignore[attr-defined]
@ribbon_action(
label="Same Size",

View File

@ -31,7 +31,7 @@ class StyleOperationsMixin:
"""
images = self._get_selected_images()
if not images:
self.show_status("No images selected", 2000)
self.show_status("No images selected", 2000) # type: ignore[attr-defined]
return
# Store old styles for undo
@ -86,10 +86,10 @@ class StyleOperationsMixin:
return None
cmd = StyleChangeCommand(old_styles, style_updater, description)
self.project.history.execute(cmd)
self.project.history.execute(cmd) # type: ignore[attr-defined]
self.update_view()
self.show_status(f"{description} applied to {len(images)} image(s)", 2000)
self.update_view() # type: ignore[attr-defined]
self.show_status(f"{description} applied to {len(images)} image(s)", 2000) # type: ignore[attr-defined]
# =========================================================================
# Corner Radius
@ -106,7 +106,7 @@ class StyleOperationsMixin:
"""Show dialog to set corner radius"""
images = self._get_selected_images()
if not images:
self.show_status("No images selected", 2000)
self.show_status("No images selected", 2000) # type: ignore[attr-defined]
return
from pyPhotoAlbum.dialogs.style_dialogs import CornerRadiusDialog
@ -151,7 +151,7 @@ class StyleOperationsMixin:
"""Show dialog to configure border"""
images = self._get_selected_images()
if not images:
self.show_status("No images selected", 2000)
self.show_status("No images selected", 2000) # type: ignore[attr-defined]
return
from pyPhotoAlbum.dialogs.style_dialogs import BorderDialog
@ -198,7 +198,7 @@ class StyleOperationsMixin:
"""Show dialog to configure drop shadow"""
images = self._get_selected_images()
if not images:
self.show_status("No images selected", 2000)
self.show_status("No images selected", 2000) # type: ignore[attr-defined]
return
from pyPhotoAlbum.dialogs.style_dialogs import ShadowDialog
@ -235,7 +235,7 @@ class StyleOperationsMixin:
"""Toggle shadow enabled/disabled"""
images = self._get_selected_images()
if not images:
self.show_status("No images selected", 2000)
self.show_status("No images selected", 2000) # type: ignore[attr-defined]
return
# Toggle based on first selected image
@ -330,7 +330,7 @@ class StyleOperationsMixin:
"""Show dialog to select decorative frame"""
images = self._get_selected_images()
if not images:
self.show_status("No images selected", 2000)
self.show_status("No images selected", 2000) # type: ignore[attr-defined]
return
from pyPhotoAlbum.dialogs.frame_picker_dialog import FramePickerDialog

View File

@ -13,11 +13,11 @@ class PageNavigationMixin:
pan_offset: Tuple[float, float]
zoom_level: float
def update(self) -> None:
def update(self) -> None: # type: ignore[empty-body]
"""Expected from QWidget"""
...
def window(self) -> "QMainWindow":
def window(self) -> "QMainWindow": # type: ignore[empty-body]
"""Expected from QWidget"""
...

View File

@ -181,7 +181,7 @@ class ViewportMixin:
content_height = bounds["height"]
# Save original for page selection (prevents clamping from changing which page we target)
original_pan_y = self.pan_offset[1]
original_pan_y: float = self.pan_offset[1]
if content_height > viewport_height:
max_pan_up = 0 # Can't pan beyond top edge

View File

@ -103,15 +103,15 @@ class ImageStyle:
"""
self.corner_radius = corner_radius
self.border_width = border_width
self.border_color = tuple(border_color)
self.border_color: Tuple[int, int, int] = border_color
self.shadow_enabled = shadow_enabled
self.shadow_offset = tuple(shadow_offset)
self.shadow_offset: Tuple[float, float] = shadow_offset
self.shadow_blur = shadow_blur
self.shadow_color = tuple(shadow_color)
self.shadow_color: Tuple[int, int, int, int] = shadow_color
self.frame_style = frame_style
self.frame_color = tuple(frame_color)
self.frame_color: Tuple[int, int, int] = frame_color
# frame_corners: (top_left, top_right, bottom_right, bottom_left)
self.frame_corners = tuple(frame_corners) if frame_corners else (True, True, True, True)
self.frame_corners: Tuple[bool, bool, bool, bool] = frame_corners if frame_corners else (True, True, True, True)
def copy(self) -> "ImageStyle":
"""Create a copy of this style."""
@ -148,7 +148,7 @@ class ImageStyle:
}
@classmethod
def deserialize(cls, data: Dict[str, Any]) -> "ImageStyle":
def deserialize(cls, data: Optional[Dict[str, Any]]) -> "ImageStyle":
"""Deserialize style from dictionary."""
if data is None:
return cls()
@ -526,7 +526,7 @@ class ImageData(BaseLayoutElement):
frame_manager = get_frame_manager()
frame_manager.render_frame_opengl(
frame_name=self.style.frame_style,
frame_name=self.style.frame_style, # type: ignore[arg-type]
x=x,
y=y,
width=w,

View File

@ -121,7 +121,7 @@ class PageLayout:
if hasattr(self, "_async_loader"):
loader = self._async_loader
elif hasattr(self, "_parent_widget") and hasattr(self._parent_widget, "async_image_loader"):
loader = self._parent_widget.async_image_loader
loader = self._parent_widget.async_image_loader # type: ignore[union-attr]
else:
loader = None
@ -131,7 +131,7 @@ class PageLayout:
# Determine priority based on visibility (HIGH for now, can be refined)
if hasattr(self._parent_widget, "request_image_load"):
self._parent_widget.request_image_load(element, priority=LoadPriority.HIGH)
self._parent_widget.request_image_load(element, priority=LoadPriority.HIGH) # type: ignore[union-attr]
element._async_load_requested = True
element._async_loading = True

View File

@ -68,7 +68,7 @@ def _process_image_task(task: ImageTask) -> Tuple[str, Optional[bytes], Optional
# Try to open the image - this is the most likely failure point
try:
img = Image.open(task.image_path)
img: Image.Image = Image.open(task.image_path)
except Exception as open_err:
import traceback
@ -389,7 +389,7 @@ class PDFExporter:
Scans all pages and elements to build a list of ImageTask objects
that can be processed in parallel.
"""
tasks = []
tasks: List[ImageTask] = []
dpi = self.project.working_dpi
for page in self.project.pages:
@ -580,7 +580,7 @@ class PDFExporter:
img.load()
# Store both image and buffer reference to prevent garbage collection
# Some PIL operations may still reference the source buffer
img._ppa_buffer = buffer # Keep buffer alive with image
img._ppa_buffer = buffer # type: ignore[attr-defined] # Keep buffer alive with image
self._processed_images[task_id] = img
except Exception as e:
@ -760,7 +760,7 @@ class PDFExporter:
y_pt=y_pt,
width_pt=width_pt,
height_pt=height_pt,
page_number=page_number,
page_number=int(page_number),
)
self._render_image(ctx)
elif isinstance(element, TextBoxData):
@ -943,7 +943,7 @@ class PDFExporter:
try:
# Load image using resolved path
img = Image.open(image_full_path)
img: Image.Image = Image.open(image_full_path)
img = convert_to_rgba(img)
# Apply PIL-level rotation if needed
@ -1072,7 +1072,7 @@ class PDFExporter:
frame_manager.render_frame_pdf(
canvas=ctx.canvas,
frame_name=style.frame_style,
frame_name=style.frame_style, # type: ignore[arg-type]
x_pt=ctx.x_pt,
y_pt=ctx.y_pt,
width_pt=ctx.width_pt,

View File

@ -342,7 +342,7 @@ class Project:
"""
from pyPhotoAlbum.models import GhostPageData
layout = []
layout: list[tuple[str, Optional["Page"], int]] = []
current_position = 1 # Start at position 1 (right page)
for page in self.pages:

View File

@ -317,7 +317,7 @@ class ThumbnailGLWidget(QOpenGLWidget):
self.current_folder = folder_path
# Find all image files
self.image_files = []
self.image_files: list[Path] = []
for ext in IMAGE_EXTENSIONS:
self.image_files.extend(folder_path.glob(f"*{ext}"))
self.image_files.extend(folder_path.glob(f"*{ext.upper()}"))