"""PySide6 desktop interface for assembling photo pages and exporting a PDF.""" from __future__ import annotations import copy import json from datetime import datetime from pathlib import Path import sys from PIL import Image, ImageOps from PySide6.QtCore import QThread, Qt, Signal, QSize, QRectF from PySide6.QtGui import QAction, QActionGroup, QColor, QFont, QImage, QPainter, QPen, QPixmap from PySide6.QtWidgets import ( QAbstractItemView, QApplication, QDialog, QDialogButtonBox, QDoubleSpinBox, QFileDialog, QFormLayout, QHBoxLayout, QLabel, QMainWindow, QMessageBox, QMenu, QProgressBar, QProgressDialog, QPushButton, QSlider, QSpinBox, QSplitter, QToolBar, QToolButton, QVBoxLayout, QWidget, QListWidget, QListWidgetItem, QLineEdit, QStatusBar, ) from blurb_book import ( BookProject, PhotoPage, PrintProfile, SUPPORTED_EXTENSIONS, export_pdf, pan_for_centered_zoom, page_zoom_bounds, photo_sort_date, render_photo, ) def _pil_to_qimage(image: Image.Image) -> QImage: rgb = image.convert("RGB") data = rgb.tobytes("raw", "RGB") return QImage(data, rgb.width, rgb.height, rgb.width * 3, QImage.Format.Format_RGB888).copy() def _page_dimensions(profile: PrintProfile) -> tuple[float, float]: return ( profile.pdf_width_in or profile.trim_width_in, profile.pdf_height_in or profile.trim_height_in, ) def _photo_size(path: str) -> tuple[int, int]: with Image.open(path) as source: image = ImageOps.exif_transpose(source) return image.size class PageListWidget(QListWidget): orderChanged = Signal() def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) self.setDefaultDropAction(Qt.DropAction.MoveAction) self.setDragDropOverwriteMode(False) self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.setWordWrap(True) self.setSpacing(5) self.setIconSize(QSize(150, 126)) self.setMinimumWidth(330) self.setMaximumWidth(390) def dropEvent(self, event) -> None: # noqa: N802 - Qt event name super().dropEvent(event) self.orderChanged.emit() class PageCanvas(QWidget): changed = Signal() editFinished = Signal() def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self.setMinimumSize(520, 420) self.setMouseTracking(True) self.setCursor(Qt.CursorShape.ArrowCursor) self.page: PhotoPage | None = None self.profile = PrintProfile() self.page_index = 0 self.page_count = 0 self._preview: QImage | None = None self._error: str | None = None self._page_rect = QRectF() self._drag_pos = None self._drag_start_pan = (0.0, 0.0) self._source_size: tuple[int, int] | None = None def set_page(self, page: PhotoPage | None, profile: PrintProfile, index: int, count: int) -> None: self.page = page self.profile = profile self.page_index = index self.page_count = count self._source_size = None self._render_preview() def set_profile(self, profile: PrintProfile) -> None: self.profile = profile self._render_preview() def _render_preview(self) -> None: self._preview = None self._error = None if self.page and self.page.image_path: try: page_w, page_h = _page_dimensions(self.profile) aspect = page_w / page_h if self.width() > 0 and self.height() > 0: available_w = max(100, self.width() - 50) available_h = max(100, self.height() - 74) if available_w / available_h > aspect: preview_h = available_h preview_w = round(preview_h * aspect) else: preview_w = available_w preview_h = round(preview_w / aspect) preview_w, preview_h = min(preview_w, 1800), min(preview_h, 1800) rendered = render_photo( self.page.image_path, (max(1, preview_w), max(1, preview_h)), self.page.zoom, self.page.pan_x, self.page.pan_y, preview=True, ) self._preview = _pil_to_qimage(rendered) rendered.close() if self._source_size is None: self._source_size = _photo_size(self.page.image_path) except (OSError, ValueError) as exc: self._error = f"Photo could not be opened:\n{exc}" self.update() def resizeEvent(self, event) -> None: # noqa: N802 - Qt event name super().resizeEvent(event) self._render_preview() def _fit_page_rect(self) -> QRectF: page_w, page_h = _page_dimensions(self.profile) aspect = page_w / page_h usable_w = max(100.0, self.width() - 48.0) usable_h = max(100.0, self.height() - 72.0) if usable_w / usable_h > aspect: height = usable_h width = height * aspect else: width = usable_w height = width / aspect return QRectF((self.width() - width) / 2, (self.height() - height) / 2 + 9, width, height) def paintEvent(self, event) -> None: # noqa: N802 - Qt event name painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.fillRect(self.rect(), QColor("#252932")) self._page_rect = self._fit_page_rect() painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(QColor("#111319")) shadow = self._page_rect.adjusted(5, 5, 5, 5) painter.drawRoundedRect(shadow, 2, 2) painter.fillRect(self._page_rect, QColor("white")) if self._preview is not None: painter.drawImage(self._page_rect, self._preview) elif self.page and self.page.image_path: painter.setPen(QColor("#a5aab3")) painter.drawText(self._page_rect, Qt.AlignmentFlag.AlignCenter, self._error or "Loading photo…") else: painter.setPen(QColor("#7a808b")) painter.drawText(self._page_rect, Qt.AlignmentFlag.AlignCenter, "Blank page") painter.setBrush(Qt.BrushStyle.NoBrush) painter.setPen(QPen(QColor("#e4e6eb"), 1)) painter.drawRect(self._page_rect) profile = self.profile pdf_w, pdf_h = _page_dimensions(profile) # PDF page 1 is right-hand. Its binding edge is the left edge; on # alternating left-hand pages, the binding is on the right. trim_left_in = ( profile.trim_binding_in if self.page_index % 2 == 0 else profile.trim_outside_in ) trim_x = self._page_rect.left() + self._page_rect.width() * trim_left_in / pdf_w trim_y = self._page_rect.top() + self._page_rect.height() * ( 1.0 - (profile.trim_bottom_in + profile.trim_height_in) / pdf_h ) trim_w = self._page_rect.width() * profile.trim_width_in / pdf_w trim_h = self._page_rect.height() * profile.trim_height_in / pdf_h trim_rect = QRectF(trim_x, trim_y, trim_w, trim_h) # If trim size equals the displayed page, the guide lands on top of the # page outline. Move it just inside the edge so the dashed guide stays # visible even before bleed dimensions are configured. trim_rect = trim_rect.adjusted( 2 if abs(trim_rect.left() - self._page_rect.left()) < 2 else 0, 2 if abs(trim_rect.top() - self._page_rect.top()) < 2 else 0, -2 if abs(trim_rect.right() - self._page_rect.right()) < 2 else 0, -2 if abs(trim_rect.bottom() - self._page_rect.bottom()) < 2 else 0, ) painter.setPen(QPen(QColor("#ffc94f"), 2, Qt.PenStyle.DashLine)) painter.drawRect(trim_rect) painter.setPen(QColor("#d1d5dc")) painter.drawText( QRectF(0, 10, self.width(), 26), Qt.AlignmentFlag.AlignCenter, f"Page {self.page_index + 1} of {self.page_count} · dashed line: trim area", ) painter.end() def mousePressEvent(self, event) -> None: # noqa: N802 - Qt event name if ( event.button() == Qt.MouseButton.LeftButton and self.page and self.page.image_path and self._preview is not None and self._page_rect.contains(event.position()) ): self._drag_pos = event.position() self._drag_start_pan = (self.page.pan_x, self.page.pan_y) self.setCursor(Qt.CursorShape.ClosedHandCursor) event.accept() return super().mousePressEvent(event) def mouseMoveEvent(self, event) -> None: # noqa: N802 - Qt event name if self._drag_pos is not None and self.page and self._source_size: point = event.position() dx = point.x() - self._drag_pos.x() dy = point.y() - self._drag_pos.y() relative_x = self._page_rect.width() / max(1, self._preview.width()) relative_y = self._page_rect.height() / max(1, self._preview.height()) image_x, image_y, image_w, image_h, _ = _cover_geometry_for_ui( self._source_size, (self._preview.width(), self._preview.height()), self.page.zoom, self._drag_start_pan[0], self._drag_start_pan[1], ) denom_x = (image_w - self._preview.width()) / 2 denom_y = (image_h - self._preview.height()) / 2 pan_x = self._drag_start_pan[0] + (dx / relative_x / denom_x if abs(denom_x) > 1e-6 else 0) pan_y = self._drag_start_pan[1] + (dy / relative_y / denom_y if abs(denom_y) > 1e-6 else 0) self.page.pan_x = max(-1.0, min(pan_x, 1.0)) self.page.pan_y = max(-1.0, min(pan_y, 1.0)) self._render_preview() self.changed.emit() event.accept() return super().mouseMoveEvent(event) def mouseReleaseEvent(self, event) -> None: # noqa: N802 - Qt event name if event.button() == Qt.MouseButton.LeftButton and self._drag_pos is not None: self._drag_pos = None self.setCursor(Qt.CursorShape.ArrowCursor) self.editFinished.emit() event.accept() return super().mouseReleaseEvent(event) def _cover_geometry_for_ui(source_size, page_size, zoom, pan_x, pan_y): source_w, source_h = source_size page_w, page_h = page_size cover = max(page_w / source_w, page_h / source_h) contain = min(page_w / source_w, page_h / source_h) scale = cover * max(contain / cover, min(zoom, 4.0)) image_w, image_h = source_w * scale, source_h * scale room_x = (page_w - image_w) / 2 room_y = (page_h - image_h) / 2 image_x = room_x * (1 - max(-1, min(pan_x, 1))) image_y = room_y * (1 - max(-1, min(pan_y, 1))) return image_x, image_y, image_w, image_h, contain / cover class PrintSetupDialog(QDialog): def __init__(self, profile: PrintProfile, parent: QWidget | None = None) -> None: super().__init__(parent) self.setWindowTitle("Blurb print setup") self.setMinimumWidth(480) self._profile = copy.deepcopy(profile) description = QLabel( "Trim size is the finished page area. Enter the final exported PDF page dimensions " "and trim offsets from the Blurb specification for your selected format, paper, " "and cover. PDF pages include the bleed area. Page limits also vary by format and paper." ) description.setWordWrap(True) self.trim_width = self._spin(profile.trim_width_in, 0.1, 30, " in") self.trim_height = self._spin(profile.trim_height_in, 0.1, 30, " in") self.pdf_width = self._line(profile.pdf_width_in) self.pdf_height = self._line(profile.pdf_height_in) self.trim_binding = self._spin(profile.trim_binding_in, 0, 30, " in") self.trim_outside = self._spin(profile.trim_outside_in, 0, 30, " in") self.trim_top = self._spin(profile.trim_top_in, 0, 30, " in") self.trim_bottom = self._spin(profile.trim_bottom_in, 0, 30, " in") self.min_pages = QSpinBox() self.min_pages.setRange(1, 2000) self.min_pages.setValue(profile.min_pages) self.max_pages = QSpinBox() self.max_pages.setRange(1, 2000) self.max_pages.setValue(profile.max_pages) form = QFormLayout() form.addRow("Trim width", self.trim_width) form.addRow("Trim height", self.trim_height) form.addRow("Final PDF page width", self.pdf_width) form.addRow("Final PDF page height", self.pdf_height) form.addRow("Trim inset from binding edge", self.trim_binding) form.addRow("Trim inset from outside edge", self.trim_outside) form.addRow("Trim inset from top edge", self.trim_top) form.addRow("Trim inset from bottom edge", self.trim_bottom) form.addRow("Minimum pages", self.min_pages) form.addRow("Maximum pages for selected format", self.max_pages) buttons = QDialogButtonBox( QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel ) buttons.accepted.connect(self._try_accept) buttons.rejected.connect(self.reject) layout = QVBoxLayout(self) layout.addWidget(description) layout.addLayout(form) layout.addWidget(buttons) @staticmethod def _spin(value: float, minimum: float, maximum: float, suffix: str) -> QDoubleSpinBox: spin = QDoubleSpinBox() spin.setRange(minimum, maximum) spin.setDecimals(3) spin.setSingleStep(0.125) spin.setSuffix(suffix) spin.setValue(value) return spin @staticmethod def _line(value: float | None) -> QLineEdit: line = QLineEdit("" if value is None else f"{value:g}") line.setPlaceholderText("Required, including bleed") return line def _try_accept(self) -> None: try: pdf_width = float(self.pdf_width.text()) pdf_height = float(self.pdf_height.text()) except ValueError: QMessageBox.warning(self, "Print setup", "Enter numeric final PDF page width and height.") return self._profile = PrintProfile( trim_width_in=self.trim_width.value(), trim_height_in=self.trim_height.value(), pdf_width_in=pdf_width, pdf_height_in=pdf_height, trim_binding_in=self.trim_binding.value(), trim_outside_in=self.trim_outside.value(), trim_top_in=self.trim_top.value(), trim_bottom_in=self.trim_bottom.value(), min_pages=self.min_pages.value(), max_pages=self.max_pages.value(), ) errors = self._profile.validate() if errors: QMessageBox.warning(self, "Print setup", "\n".join(errors)) return self.accept() @property def profile(self) -> PrintProfile: return self._profile class ExportWorker(QThread): progressChanged = Signal(int, int) succeeded = Signal(str) failed = Signal(str) def __init__(self, project: BookProject, output_path: str) -> None: super().__init__() self.project = project self.output_path = output_path def run(self) -> None: try: result = export_pdf( self.project, self.output_path, progress=lambda done, total: self.progressChanged.emit(done, total), ) self.succeeded.emit(str(result)) except Exception as exc: # The error is shown in the app so export failures are actionable. self.failed.emit(str(exc)) class FolderImportWorker(QThread): stageChanged = Signal(str, int) progressChanged = Signal(int, int) completed = Signal(object, object, object, object) failed = Signal(str) def __init__(self, folder: str) -> None: super().__init__() self.folder = folder def run(self) -> None: try: project, issues = BookProject.from_folder(self.folder) total = len(project.pages) self.stageChanged.emit("Preparing photo previews…", total) photo_dates: dict[str, str] = {} thumbnails: dict[int, QImage] = {} page_width, page_height = _page_dimensions(project.profile) thumbnail_width = 150 thumbnail_height = max(1, round(thumbnail_width * page_height / page_width)) for index, page in enumerate(project.pages, start=1): capture_date = project.capture_dates.get(page.image_path or "") if capture_date is not None and capture_date != datetime.max and page.image_path: photo_dates[page.image_path] = capture_date.strftime("%m/%d/%Y") try: preview = render_photo( page.image_path or "", (thumbnail_width, thumbnail_height), page.zoom, page.pan_x, page.pan_y, preview=True, ) try: thumbnails[id(page)] = _pil_to_qimage(preview) finally: preview.close() except Exception: placeholder = Image.new( "RGB", (thumbnail_width, thumbnail_height), "#e8c7c7" ) thumbnails[id(page)] = _pil_to_qimage(placeholder) placeholder.close() self.progressChanged.emit(index, total) if project.pages and project.pages[0].image_path: first_page = project.pages[0] try: first_preview = render_photo( first_page.image_path, (thumbnail_width, thumbnail_height), first_page.zoom, first_page.pan_x, first_page.pan_y, preview=True, ) first_preview.close() except (OSError, ValueError): pass self.completed.emit(project, issues, photo_dates, thumbnails) except Exception as exc: self.failed.emit(str(exc)) class PhotoBookWindow(QMainWindow): def __init__(self) -> None: super().__init__() self.setWindowTitle("Blurb Photo Book Editor") self.resize(1480, 920) self.project = BookProject() self.project_path: str | None = None self._photo_date_cache: dict[str, str] = {} self._photo_thumbnail_cache: dict[int, QPixmap] = {} self.view_mode = "all" self.dirty = False self.current_page_index = -1 self.worker: ExportWorker | None = None self.folder_import_worker: FolderImportWorker | None = None self.import_progress_dialog: QProgressDialog | None = None self._pending_import_issues: list[str] = [] self._pending_import_error: str | None = None self._pending_empty_import = False self.pages_list = PageListWidget() self.pages_list.currentRowChanged.connect(self._selection_changed) self.pages_list.orderChanged.connect(self._order_changed) self.pages_list.customContextMenuRequested.connect(self._show_page_context_menu) self.canvas = PageCanvas() self.canvas.changed.connect(self._mark_dirty) self.canvas.editFinished.connect(self._selected_thumbnail_changed) self.zoom_slider = QSlider(Qt.Orientation.Horizontal) self.zoom_slider.setRange(0, 100) self.zoom_slider.valueChanged.connect(self._zoom_changed) self.zoom_label = QLabel("100% of cover") self.zoom_out = QToolButton() self.zoom_out.setText("−") self.zoom_out.setToolTip("Zoom out") self.zoom_out.clicked.connect(lambda: self._step_zoom(1 / 1.12)) self.zoom_in = QToolButton() self.zoom_in.setText("+") self.zoom_in.setToolTip("Zoom in") self.zoom_in.clicked.connect(lambda: self._step_zoom(1.12)) self.insert_blank_button = QPushButton("Insert blank") self.insert_blank_button.clicked.connect(self._insert_blank_page) self.insert_photo_button = QPushButton("Insert photo") self.insert_photo_button.clicked.connect(self._insert_photo_page) self.remove_button = QPushButton("Remove page") self.remove_button.clicked.connect(self._remove_page) self.relink_button = QPushButton("Relink photo…") self.relink_button.clicked.connect(self._relink_photo) left = QWidget() left_layout = QVBoxLayout(left) left_layout.setContentsMargins(10, 10, 10, 10) self.pages_heading = QLabel("PAGES · drag to reorder") left_layout.addWidget(self.pages_heading) left_layout.addWidget(self.pages_list, 1) page_controls = QHBoxLayout() page_controls.addWidget(self.insert_blank_button) page_controls.addWidget(self.insert_photo_button) left_layout.addLayout(page_controls) page_controls2 = QHBoxLayout() page_controls2.addWidget(self.remove_button) page_controls2.addWidget(self.relink_button) left_layout.addLayout(page_controls2) right = QWidget() right_layout = QVBoxLayout(right) right_layout.setContentsMargins(12, 10, 12, 10) right_layout.addWidget(self.canvas, 1) zoom_controls = QHBoxLayout() zoom_controls.addStretch(1) zoom_controls.addWidget(QLabel("Zoom")) zoom_controls.addWidget(self.zoom_out) zoom_controls.addWidget(self.zoom_slider, 1) zoom_controls.addWidget(self.zoom_in) zoom_controls.addWidget(self.zoom_label) zoom_controls.addStretch(1) right_layout.addLayout(zoom_controls) hint = QLabel("Drag inside the page to reposition the photo. Photos start cropped to fill the page.") hint.setAlignment(Qt.AlignmentFlag.AlignCenter) hint.setStyleSheet("color: #aab1bd; padding: 3px;") right_layout.addWidget(hint) splitter = QSplitter(Qt.Orientation.Horizontal) splitter.addWidget(left) splitter.addWidget(right) splitter.setStretchFactor(0, 0) splitter.setStretchFactor(1, 1) splitter.setSizes([360, 1120]) self.setCentralWidget(splitter) self._create_actions() self._create_status() self._update_actions() self._set_dirty(False) self._refresh_canvas() def _create_actions(self) -> None: file_menu = self.menuBar().addMenu("&File") self.open_folder_action = QAction("Import photo folder…", self) self.open_folder_action.triggered.connect(self._import_folder) file_menu.addAction(self.open_folder_action) self.open_project_action = QAction("Open project…", self) self.open_project_action.triggered.connect(self._open_project) file_menu.addAction(self.open_project_action) self.save_action = QAction("Save project", self) self.save_action.setShortcut("Ctrl+S") self.save_action.triggered.connect(self._save_project) file_menu.addAction(self.save_action) self.save_as_action = QAction("Save project as…", self) self.save_as_action.setShortcut("Ctrl+Shift+S") self.save_as_action.triggered.connect(self._save_project_as) file_menu.addAction(self.save_as_action) file_menu.addSeparator() self.print_setup_action = QAction("Blurb print setup…", self) self.print_setup_action.triggered.connect(self._edit_print_setup) file_menu.addAction(self.print_setup_action) self.export_action = QAction("Export PDF…", self) self.export_action.setShortcut("Ctrl+E") self.export_action.triggered.connect(self._export_pdf) file_menu.addAction(self.export_action) file_menu.addSeparator() exit_action = QAction("Exit", self) exit_action.triggered.connect(self.close) file_menu.addAction(exit_action) view_menu = self.menuBar().addMenu("&View") self.view_action_group = QActionGroup(self) self.view_action_group.setExclusive(True) self.view_all_action = QAction("View All", self) self.view_all_action.setCheckable(True) self.view_all_action.setChecked(True) self.view_all_action.triggered.connect(lambda checked=False: self._set_view_mode("all")) self.view_action_group.addAction(self.view_all_action) view_menu.addAction(self.view_all_action) self.view_tagged_action = QAction("View Tagged", self) self.view_tagged_action.setCheckable(True) self.view_tagged_action.triggered.connect(lambda checked=False: self._set_view_mode("tagged")) self.view_action_group.addAction(self.view_tagged_action) view_menu.addAction(self.view_tagged_action) toolbar = QToolBar("Main") toolbar.setMovable(False) self.addToolBar(toolbar) for action in ( self.open_folder_action, self.open_project_action, self.save_action, self.print_setup_action, self.export_action, ): toolbar.addAction(action) self.progress = QProgressBar() self.progress.setMaximumWidth(230) self.progress.setFixedHeight(18) self.progress.setVisible(False) toolbar.addWidget(self.progress) def _create_status(self) -> None: status = QStatusBar() self.setStatusBar(status) self.status_label = QLabel("Import a folder of photos to begin.") status.addWidget(self.status_label, 1) def _set_dirty(self, dirty: bool) -> None: self.dirty = dirty path_label = Path(self.project_path).name if self.project_path else "Untitled project" self.setWindowTitle(f"{'* ' if dirty else ''}{path_label} — Blurb Photo Book Editor") self.save_action.setEnabled(dirty or self.project_path is None) def _mark_dirty(self) -> None: self._set_dirty(True) self._update_status() def _update_status(self) -> None: count = len(self.project.pages) photo_count = sum(1 for page in self.project.pages if page.image_path) odd = " · Blurb export needs an even page count" if count % 2 else "" if count < self.project.profile.min_pages or count > self.project.profile.max_pages: odd += ( f" · outside page range {self.project.profile.min_pages}–" f"{self.project.profile.max_pages}" ) missing = sum( 1 for page in self.project.pages if page.image_path and not Path(page.image_path).is_file() ) missing_text = f" · {missing} missing photo(s)" if missing else "" setup = "Print setup needed" if not self.project.profile.ready else "Print setup ready" self.status_label.setText(f"{count} pages · {photo_count} photo pages · {setup}{odd}{missing_text}") def _update_actions(self) -> None: self._sync_view_menu() selected = self._page_at_row(self.pages_list.currentRow()) has_selection = selected is not None self.remove_button.setEnabled(has_selection) can_insert = self.view_mode == "all" self.insert_blank_button.setEnabled(can_insert) self.insert_photo_button.setEnabled(can_insert) insert_tip = "Switch to View All to insert pages." if not can_insert else "" self.insert_blank_button.setToolTip(insert_tip) self.insert_photo_button.setToolTip(insert_tip) missing = bool(selected and selected.image_path and not Path(selected.image_path).is_file()) self.relink_button.setEnabled(missing) self.zoom_slider.setEnabled(bool(selected and selected.image_path and Path(selected.image_path).is_file())) self.zoom_in.setEnabled(self.zoom_slider.isEnabled()) self.zoom_out.setEnabled(self.zoom_slider.isEnabled()) self._update_status() def _page_at_row(self, row: int) -> PhotoPage | None: if not 0 <= row < self.pages_list.count(): return None page = self.pages_list.item(row).data(Qt.ItemDataRole.UserRole) return page if isinstance(page, PhotoPage) else None def _project_index_for_page(self, page: PhotoPage | None) -> int: if page is None: return -1 return next( (index for index, candidate in enumerate(self.project.pages) if candidate is page), -1, ) def _has_tagged_photos(self) -> bool: return any(page.image_path and page.tagged for page in self.project.pages) def _visible_page_entries(self) -> list[tuple[int, PhotoPage]]: return [ (index, page) for index, page in enumerate(self.project.pages) if self.view_mode == "all" or (page.image_path and page.tagged) ] def _sync_view_menu(self) -> None: has_tagged_photos = self._has_tagged_photos() if self.view_mode == "tagged" and not has_tagged_photos: self.view_mode = "all" if hasattr(self, "view_tagged_action"): self.view_tagged_action.setEnabled(has_tagged_photos) self.view_all_action.setChecked(self.view_mode == "all") self.view_tagged_action.setChecked(self.view_mode == "tagged") if self.view_mode == "all": self.pages_list.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) self.pages_heading.setText("PAGES · drag to reorder") else: self.pages_list.setDragDropMode(QAbstractItemView.DragDropMode.NoDragDrop) self.pages_heading.setText("PAGES · tagged only") def _set_view_mode(self, mode: str) -> None: if mode == "tagged" and not self._has_tagged_photos(): return selected_index = self._project_index_for_page(self._page_at_row(self.pages_list.currentRow())) self.view_mode = mode self._sync_view_menu() self._refresh_page_list(selected_index if selected_index >= 0 else None) def _page_icon(self, page: PhotoPage) -> QPixmap: if not page.image_path or not Path(page.image_path).is_file(): pixmap = QPixmap(150, 126) pixmap.fill(QColor("#e8e9ed")) else: pixmap = self._photo_thumbnail_cache.get(id(page)) if pixmap is None: aspect = (self.project.profile.pdf_width_in or self.project.profile.trim_width_in) / ( self.project.profile.pdf_height_in or self.project.profile.trim_height_in ) thumb_w = 150 thumb_h = max(1, round(thumb_w / aspect)) image: Image.Image | None = None try: image = render_photo( page.image_path, (thumb_w, thumb_h), page.zoom, page.pan_x, page.pan_y, preview=True, ) pixmap = QPixmap.fromImage(_pil_to_qimage(image)) except (OSError, ValueError): pixmap = QPixmap(150, 126) pixmap.fill(QColor("#e8c7c7")) finally: if image is not None: image.close() self._photo_thumbnail_cache[id(page)] = QPixmap(pixmap) else: pixmap = QPixmap(pixmap) if page.tagged: painter = QPainter(pixmap) painter.setRenderHint(QPainter.RenderHint.Antialiasing) badge_size = 24 badge = QRectF(pixmap.width() - badge_size - 5, 5, badge_size, badge_size) painter.setPen(QPen(QColor("white"), 2)) painter.setBrush(QColor("#e53935")) painter.drawEllipse(badge) painter.setPen(QColor("white")) font = QFont() font.setBold(True) font.setPointSize(10) painter.setFont(font) painter.drawText(badge, Qt.AlignmentFlag.AlignCenter, "T") painter.end() return pixmap def _photo_date_label(self, path: str) -> str: if path not in self._photo_date_cache: date_text = "" if Path(path).is_file(): capture_date = photo_sort_date(path) if capture_date != datetime.max: date_text = capture_date.strftime("%m/%d/%Y") self._photo_date_cache[path] = date_text else: return "" return self._photo_date_cache[path] def _page_label(self, page: PhotoPage, index: int) -> str: if not page.image_path: return f"Page {index + 1:03d} · Blank page" elif not Path(page.image_path).is_file(): detail = f"Page {index + 1:03d} · Missing · {Path(page.image_path).name}" else: detail = f"Page {index + 1:03d} · {Path(page.image_path).name}" date_text = self._photo_date_label(page.image_path) return f"{detail}\n{date_text}" if date_text else detail return detail def _refresh_page_list(self, selected_index: int | None = None) -> None: if selected_index is None: selected_index = self._project_index_for_page( self._page_at_row(self.pages_list.currentRow()) ) self._sync_view_menu() entries = self._visible_page_entries() selected_row = -1 self.pages_list.blockSignals(True) self.pages_list.clear() for index, page in entries: item = QListWidgetItem(self._page_label(page, index)) item.setData(Qt.ItemDataRole.UserRole, page) item.setIcon(self._page_icon(page)) item.setToolTip("Tagged page" if page.tagged else "") item.setSizeHint(QSize(310, 142)) self.pages_list.addItem(item) if entries: selected_row = next( (row for row, (index, _) in enumerate(entries) if index == selected_index), next( (row for row, (index, _) in enumerate(entries) if index >= selected_index), len(entries) - 1, ), ) self.pages_list.blockSignals(False) self.pages_list.setCurrentRow(selected_row) self._selection_changed(self.pages_list.currentRow()) self._update_actions() def _refresh_one_page(self, index: int, *, refresh_thumbnail: bool = False) -> None: if 0 <= index < self.pages_list.count(): page = self._page_at_row(index) if page is None: return if refresh_thumbnail: self._photo_thumbnail_cache.pop(id(page), None) project_index = self._project_index_for_page(page) item = self.pages_list.item(index) item.setText(self._page_label(page, project_index)) item.setIcon(self._page_icon(page)) item.setToolTip("Tagged page" if page.tagged else "") def _refresh_canvas(self) -> None: page = self._page_at_row(self.pages_list.currentRow()) project_index = self._project_index_for_page(page) self.canvas.set_page( page, self.project.profile, project_index if page else 0, len(self.project.pages), ) def _selection_changed(self, row: int) -> None: page = self._page_at_row(row) project_index = self._project_index_for_page(page) self.current_page_index = project_index self.canvas.set_page( page, self.project.profile, project_index if page else 0, len(self.project.pages), ) self._sync_zoom_control() self._update_actions() def _sync_zoom_control(self) -> None: row = self.pages_list.currentRow() page = self._page_at_row(row) if not page or not page.image_path or not Path(page.image_path).is_file(): self.zoom_slider.blockSignals(True) self.zoom_slider.setValue(0) self.zoom_slider.blockSignals(False) self.zoom_label.setText("—") return width, height = _page_dimensions(self.project.profile) min_zoom, max_zoom = page_zoom_bounds(page.image_path, width / height) value = round((page.zoom - min_zoom) / max(0.001, max_zoom - min_zoom) * 100) self.zoom_slider.blockSignals(True) self.zoom_slider.setValue(max(0, min(100, value))) self.zoom_slider.blockSignals(False) self.zoom_label.setText(f"{page.zoom * 100:.0f}% of cover") def _zoom_changed(self, value: int) -> None: row = self.pages_list.currentRow() page = self._page_at_row(row) if page is None: return if not page.image_path or not Path(page.image_path).is_file(): return width, height = _page_dimensions(self.project.profile) min_zoom, max_zoom = page_zoom_bounds(page.image_path, width / height) next_zoom = min_zoom + (max_zoom - min_zoom) * value / 100 page.pan_x, page.pan_y = pan_for_centered_zoom( _photo_size(page.image_path), (width, height), page.zoom, next_zoom, page.pan_x, page.pan_y, ) page.zoom = next_zoom self.zoom_label.setText(f"{page.zoom * 100:.0f}% of cover") self.canvas.set_page( page, self.project.profile, self._project_index_for_page(page), len(self.project.pages), ) self._mark_dirty() self._refresh_one_page(row, refresh_thumbnail=True) def _step_zoom(self, factor: float) -> None: row = self.pages_list.currentRow() page = self._page_at_row(row) if page is None: return if not page.image_path or not Path(page.image_path).is_file(): return width, height = _page_dimensions(self.project.profile) min_zoom, max_zoom = page_zoom_bounds(page.image_path, width / height) next_zoom = max(min_zoom, min(max_zoom, page.zoom * factor)) page.pan_x, page.pan_y = pan_for_centered_zoom( _photo_size(page.image_path), (width, height), page.zoom, next_zoom, page.pan_x, page.pan_y, ) page.zoom = next_zoom self._sync_zoom_control() self.canvas.set_page( page, self.project.profile, self._project_index_for_page(page), len(self.project.pages), ) self._mark_dirty() self._refresh_one_page(row, refresh_thumbnail=True) def _selected_thumbnail_changed(self) -> None: row = self.pages_list.currentRow() self._refresh_one_page(row, refresh_thumbnail=True) self._sync_zoom_control() self._mark_dirty() def _order_changed(self) -> None: if self.view_mode != "all": self._refresh_page_list() return self.project.pages = [ self.pages_list.item(index).data(Qt.ItemDataRole.UserRole) for index in range(self.pages_list.count()) ] for index in range(self.pages_list.count()): self.pages_list.item(index).setText(self._page_label(self.project.pages[index], index)) self._refresh_canvas() self._mark_dirty() def _show_page_context_menu(self, position) -> None: item = self.pages_list.itemAt(position) if item is None: return row = self.pages_list.row(item) self.pages_list.setCurrentRow(row) page = self._page_at_row(row) if page is None: return menu = QMenu(self) action = menu.addAction("Untag page" if page.tagged else "Tag page") action.triggered.connect(lambda checked=False, selected_row=row: self._toggle_page_tag(selected_row)) menu.exec(self.pages_list.mapToGlobal(position)) def _toggle_page_tag(self, row: int) -> None: page = self._page_at_row(row) if page is None: return project_index = self._project_index_for_page(page) page.tagged = not page.tagged if self.view_mode == "tagged": self._refresh_page_list(project_index) else: self._refresh_one_page(row) self._sync_view_menu() self._mark_dirty() def _insert_at(self) -> int: selected = self._project_index_for_page(self._page_at_row(self.pages_list.currentRow())) return selected + 1 if selected >= 0 else len(self.project.pages) def _insert_blank_page(self) -> None: index = self._insert_at() self.project.pages.insert(index, PhotoPage()) self._set_dirty(True) self._refresh_page_list(index) def _insert_photo_page(self) -> None: folder = self.project.source_directory or str(Path.home()) filters = "Photo files (" + " ".join(f"*{ext}" for ext in sorted(SUPPORTED_EXTENSIONS)) + ")" path, _ = QFileDialog.getOpenFileName(self, "Choose a photo for the new page", folder, filters) if not path: return path = str(Path(path).resolve()) if path not in self.project.photo_paths: self.project.photo_paths.append(path) index = self._insert_at() self.project.pages.insert(index, PhotoPage(image_path=path)) self._set_dirty(True) self._refresh_page_list(index) def _remove_page(self) -> None: row = self.pages_list.currentRow() page = self._page_at_row(row) if page is None: return project_index = self._project_index_for_page(page) detail = Path(page.image_path).name if page.image_path else "blank page" box = QMessageBox(self) box.setWindowTitle("Remove page") box.setText(f"Remove this page ({detail}) from the project? The source photo will not be deleted.") remove = box.addButton("Remove page", QMessageBox.ButtonRole.DestructiveRole) box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole) box.exec() if box.clickedButton() is not remove: return del self.project.pages[project_index] self._photo_thumbnail_cache.pop(id(page), None) self._set_dirty(True) self._refresh_page_list(project_index) def _relink_photo(self) -> None: row = self.pages_list.currentRow() page = self._page_at_row(row) if page is None: return project_index = self._project_index_for_page(page) old = page.image_path start = self.project.source_directory or str(Path.home()) if old: parent = Path(old).parent if parent.is_dir(): start = str(parent) filters = "Photo files (" + " ".join(f"*{ext}" for ext in sorted(SUPPORTED_EXTENSIONS)) + ")" path, _ = QFileDialog.getOpenFileName(self, "Relink missing photo", start, filters) if not path: return path = str(Path(path).resolve()) page.image_path = path self._photo_thumbnail_cache.pop(id(page), None) if path not in self.project.photo_paths: self.project.photo_paths.append(path) self._set_dirty(True) self._refresh_page_list(project_index) def _maybe_save(self) -> bool: if not self.dirty: return True box = QMessageBox(self) box.setWindowTitle("Unsaved project") box.setText("Save changes to the current photo book project?") box.setStandardButtons( QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel ) box.setDefaultButton(QMessageBox.StandardButton.Save) choice = box.exec() if choice == QMessageBox.StandardButton.Cancel: return False if choice == QMessageBox.StandardButton.Save: return self._save_project() return True def _import_folder(self) -> None: if self.folder_import_worker and self.folder_import_worker.isRunning(): return if not self._maybe_save(): return folder = QFileDialog.getExistingDirectory(self, "Choose photo folder", self.project.source_directory or str(Path.home())) if not folder: return self._pending_import_issues = [] self._pending_import_error = None self._pending_empty_import = False dialog = QProgressDialog("Scanning photo folder…", "", 0, 0, self) dialog.setWindowTitle("Importing photos") dialog.setWindowModality(Qt.WindowModality.WindowModal) dialog.setCancelButton(None) dialog.setMinimumDuration(0) dialog.setAutoClose(False) dialog.setAutoReset(False) dialog.setWindowFlags(dialog.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint) self.import_progress_dialog = dialog self.folder_import_worker = FolderImportWorker(folder) self.folder_import_worker.stageChanged.connect(self._folder_import_stage_changed) self.folder_import_worker.progressChanged.connect(self._folder_import_progress_changed) self.folder_import_worker.completed.connect(self._folder_import_completed) self.folder_import_worker.failed.connect(self._folder_import_failed) self.folder_import_worker.finished.connect(self._folder_import_worker_finished) dialog.show() self.folder_import_worker.start() def _folder_import_stage_changed(self, message: str, total: int) -> None: if not self.import_progress_dialog: return self.import_progress_dialog.setLabelText(message) self.import_progress_dialog.setRange(0, total) self.import_progress_dialog.setValue(0) def _folder_import_progress_changed(self, completed: int, total: int) -> None: if self.import_progress_dialog: self.import_progress_dialog.setLabelText( f"Preparing photo previews… {completed} of {total}" ) self.import_progress_dialog.setValue(completed) def _folder_import_completed( self, project: BookProject, issues: list[str], photo_dates: dict[str, str], thumbnails: dict[int, QImage], ) -> None: self._pending_import_issues = issues if not project.photo_paths: self._pending_empty_import = True return self.project = project self.project_path = None self._photo_date_cache = photo_dates self._photo_thumbnail_cache = { page_id: QPixmap.fromImage(image) for page_id, image in thumbnails.items() } self._set_dirty(True) self._refresh_page_list(0) self.status_label.setText( f"Imported {len(project.photo_paths)} photo(s), sorted oldest to newest." ) def _folder_import_failed(self, message: str) -> None: self._pending_import_error = message def _folder_import_worker_finished(self) -> None: worker = self.folder_import_worker self.folder_import_worker = None if self.import_progress_dialog: self.import_progress_dialog.close() self.import_progress_dialog.deleteLater() self.import_progress_dialog = None if worker: worker.deleteLater() if self._pending_import_error: QMessageBox.critical(self, "Import photos", self._pending_import_error) elif self._pending_empty_import: QMessageBox.warning( self, "Import photos", "No readable photos were found in that folder.", ) elif self._pending_import_issues: preview = "\n".join(self._pending_import_issues[:12]) if len(self._pending_import_issues) > 12: preview += f"\n…and {len(self._pending_import_issues) - 12} more." QMessageBox.warning(self, "Some photos could not be read", preview) self._pending_import_issues = [] self._pending_import_error = None self._pending_empty_import = False def _open_project(self) -> None: if not self._maybe_save(): return path, _ = QFileDialog.getOpenFileName( self, "Open photo book project", str(Path.home()), "Blurb projects (*.blurbproj);;JSON files (*.json)" ) if not path: return try: project = BookProject.load(path) except (OSError, ValueError, json.JSONDecodeError) as exc: QMessageBox.critical(self, "Open project", f"Could not open the project:\n{exc}") return self.project = project self.project_path = path self._photo_date_cache.clear() self._photo_thumbnail_cache.clear() self._set_dirty(False) self._refresh_page_list(0) def _save_project(self) -> bool: if self.project_path is None: return self._save_project_as() try: self.project.save(self.project_path) except OSError as exc: QMessageBox.critical(self, "Save project", str(exc)) return False self._set_dirty(False) self.status_label.setText(f"Saved project: {self.project_path}") return True def _save_project_as(self) -> bool: path, _ = QFileDialog.getSaveFileName( self, "Save photo book project", self.project_path or str(Path(self.project.source_directory or Path.home()) / "photo-book.blurbproj"), "Blurb projects (*.blurbproj)", ) if not path: return False if not path.lower().endswith(".blurbproj"): path += ".blurbproj" old_path = self.project_path self.project_path = path if not self._save_project(): self.project_path = old_path return False return True def _edit_print_setup(self) -> None: dialog = PrintSetupDialog(self.project.profile, self) if dialog.exec() != QDialog.DialogCode.Accepted: return self.project.profile = dialog.profile self._set_dirty(True) self._refresh_canvas() self._sync_zoom_control() self._update_status() def _export_pdf(self) -> None: if self.worker and self.worker.isRunning(): return if not self.project.pages: QMessageBox.information(self, "Export PDF", "Import photos or insert a page first.") return if not self.project.profile.ready: QMessageBox.information( self, "Blurb print setup required", "Enter the final exported page dimensions and trim offsets from Blurb’s specifications first.", ) self._edit_print_setup() if not self.project.profile.ready: return errors = self.project.profile.validate() if errors: QMessageBox.warning(self, "Blurb print setup", "\n".join(errors)) return count = len(self.project.pages) if count > self.project.profile.max_pages: QMessageBox.warning( self, "Page limit exceeded", f"This Blurb format allows at most {self.project.profile.max_pages} pages. " "Remove pages or update the limit from the selected Blurb specifications.", ) return target_count = max(count, self.project.profile.min_pages) if target_count % 2: target_count += 1 if target_count > self.project.profile.max_pages: QMessageBox.warning( self, "Page limits conflict", "The minimum page count and even-page rule exceed the maximum for this format. " "Review the Blurb page limits in print setup.", ) return if target_count > count: add_count = target_count - count box = QMessageBox(self) box.setWindowTitle("Blurb page count") box.setText( f"This format requires an even page count between {self.project.profile.min_pages} " f"and {self.project.profile.max_pages}. Append {add_count} blank page(s) to continue?" ) append = box.addButton( f"Append {add_count} blank page(s)", QMessageBox.ButtonRole.AcceptRole ) box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole) box.exec() if box.clickedButton() is not append: return self.project.pages.extend(PhotoPage() for _ in range(add_count)) self._set_dirty(True) self._refresh_page_list(len(self.project.pages) - 1) suggested = Path(self.project.source_directory or Path.home()) / "blurb-book.pdf" output, _ = QFileDialog.getSaveFileName(self, "Export Blurb PDF", str(suggested), "PDF files (*.pdf)") if not output: return if not output.lower().endswith(".pdf"): output += ".pdf" snapshot = copy.deepcopy(self.project) self.worker = ExportWorker(snapshot, output) self.worker.progressChanged.connect(self._export_progress) self.worker.succeeded.connect(self._export_succeeded) self.worker.failed.connect(self._export_failed) self.worker.start() self.export_action.setEnabled(False) self.progress.setRange(0, len(snapshot.pages)) self.progress.setValue(0) self.progress.setVisible(True) self.status_label.setText("Rendering pages…") def _export_progress(self, done: int, total: int) -> None: self.progress.setRange(0, total) self.progress.setValue(done) self.status_label.setText(f"Rendering page {done} of {total}…") def _export_succeeded(self, path: str) -> None: self.export_action.setEnabled(True) self.progress.setVisible(False) self.status_label.setText(f"PDF verified and saved: {path}") QMessageBox.information(self, "PDF ready", f"PDF created and verified:\n{path}") def _export_failed(self, message: str) -> None: self.export_action.setEnabled(True) self.progress.setVisible(False) self.status_label.setText("PDF export failed.") QMessageBox.critical(self, "PDF export failed", message) def closeEvent(self, event) -> None: # noqa: N802 - Qt event name if self.folder_import_worker and self.folder_import_worker.isRunning(): QMessageBox.information( self, "Photo import running", "Wait for the photo import to finish before closing.", ) event.ignore() return if self.worker and self.worker.isRunning(): QMessageBox.information(self, "PDF export running", "Wait for the PDF export to finish before closing.") event.ignore() return if self._maybe_save(): event.accept() else: event.ignore() def run() -> int: app = QApplication(sys.argv) app.setApplicationName("Blurb Photo Book Editor") app.setStyle("Fusion") window = PhotoBookWindow() window.show() return app.exec()