"""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, QPointF, QSettings from PySide6.QtGui import QAction, QActionGroup, QColor, QFont, QFontDatabase, QFontMetricsF, QImage, QPainter, QPainterPath, QPen, QPixmap from PySide6.QtWidgets import ( QAbstractItemView, QApplication, QColorDialog, QDialog, QDialogButtonBox, QDoubleSpinBox, QFileDialog, QFormLayout, QFontComboBox, QHBoxLayout, QLabel, QMainWindow, QMessageBox, QMenu, QProgressBar, QProgressDialog, QPushButton, QSlider, QSpinBox, QSplitter, QStackedWidget, QToolBar, QToolButton, QVBoxLayout, QWidget, QListWidget, QListWidgetItem, QLineEdit, QStatusBar, ) from blurb_book import ( BookProject, CoverLayout, EXPORT_PPI, FRONT_COVER_OUTLINE_RADIUS_PX, PhotoPage, PrintProfile, SUPPORTED_EXTENSIONS, SPINE_SUBTITLE_ZONE_HEIGHT_IN, export_cover_pdf, export_pdf, pan_for_centered_zoom, page_zoom_bounds, photo_sort_date, render_photo, scan_photo_folder, ) 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 def _photo_path_key(path: str | Path) -> str: value = str(path) try: value = str(Path(value).expanduser().resolve()) except (OSError, RuntimeError, ValueError): pass return value.casefold() def _resolve_cover_font_families(cover: CoverLayout) -> None: available = {family.casefold(): family for family in QFontDatabase.families()} fallback = available.get("arial", "Sans Serif") for attribute in ( "front_font_family", "title_font_family", "subtitle_font_family", ): family = getattr(cover, attribute) setattr(cover, attribute, available.get(family.casefold(), fallback)) 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 CoverCanvas(QWidget): changed = Signal() editFinished = Signal() activeSideChanged = Signal(str) def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self.setMinimumSize(620, 360) self.setMouseTracking(True) self.cover = CoverLayout() self.active_side = "front" self._previews: dict[str, QImage] = {} self._source_sizes: dict[str, tuple[int, int]] = {} self._spread_rect = QRectF() self._panel_rects: dict[str, QRectF] = {} self._drag_pos: QPointF | None = None self._drag_side: str | None = None self._drag_start_pan = (0.0, 0.0) self._drag_text_role: str | None = None self._drag_start_text_position = (0.5, 0.17) self._front_text_hit_rects: dict[str, QRectF] = {} self._front_text_bounds: dict[str, QRectF] = {} self._selected_front_text: str | None = None self._spine_text_hit_rects: dict[str, QRectF] = {} self._selected_spine_text: str | None = None self._drag_spine_role: str | None = None self._drag_start_spine_y = 0.0 self._drag_spine_half_extent_in = 0.0 self._drag_other_spine_y: float | None = None self._drag_other_spine_half_extent_in = 0.0 def set_cover(self, cover: CoverLayout) -> None: self.cover = cover self._render_previews() def set_active_side(self, side: str) -> None: if side not in ("front", "back"): return self.active_side = side self.update() def resizeEvent(self, event) -> None: # noqa: N802 - Qt event name super().resizeEvent(event) self._render_previews() def _margins(self) -> tuple[float, float, float, float]: return ( float(self.cover.margin_left_in or 0.0), float(self.cover.margin_right_in or 0.0), float(self.cover.margin_top_in or 0.0), float(self.cover.margin_bottom_in or 0.0), ) def _layout_rects(self) -> None: margin_left, _, margin_top, _ = self._margins() spread_width, spread_height = self.cover.spread_size_in aspect = spread_width / spread_height if spread_height else 1.0 usable_w = max(100.0, self.width() - 50.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 self._spread_rect = QRectF( (self.width() - width) / 2, (self.height() - height) / 2 + 10, width, height, ) scale = width / spread_width if spread_width else 1.0 face_top = self._spread_rect.top() + margin_top * scale face_h = self.cover.face_height_in * scale back_left = self._spread_rect.left() + margin_left * scale face_w = self.cover.face_width_in * scale spine_w = self.cover.spine_width_in * scale back_rect = QRectF(back_left, face_top, face_w, face_h) spine_rect = QRectF(back_rect.right(), face_top, spine_w, face_h) front_rect = QRectF(spine_rect.right(), face_top, face_w, face_h) self._panel_rects = {"back": back_rect, "front": front_rect, "spine": spine_rect} def _render_previews(self) -> None: self._layout_rects() self._previews.clear() self._source_sizes.clear() for side, page in (("back", self.cover.back_page), ("front", self.cover.front_page)): if not page or not page.image_path or not Path(page.image_path).is_file(): continue rect = self._panel_rects[side] preview_size = ( max(1, round(rect.width() * 1.6)), max(1, round(rect.height() * 1.6)), ) try: preview = render_photo( page.image_path, preview_size, page.zoom, page.pan_x, page.pan_y, preview=True, ) self._previews[side] = _pil_to_qimage(preview) preview.close() self._source_sizes[side] = _photo_size(page.image_path) except (OSError, ValueError): continue self.update() @staticmethod def _fitted_font_size( text: str, family: str, base_size: float, max_length_pt: float, ) -> float: if not text.strip(): return base_size metrics_font = QFont(family) metrics_font.setPointSizeF(base_size) measured = QFontMetricsF(metrics_font).horizontalAdvance(text) * 72 / 96 return min(base_size, base_size * max_length_pt / measured) if measured else base_size def _paint_spine_text( self, painter: QPainter, role: str, text: str, y_from_bottom: float, max_length_pt: float, family: str, font_size_pt: float, color: str, ) -> None: if not text.strip(): return spine_rect = self._panel_rects["spine"] face_h = self.cover.face_height_in font_pt = self._fitted_font_size(text, family, font_size_pt, max_length_pt) scale = spine_rect.width() / self.cover.spine_width_in if self.cover.spine_width_in else 1.0 px_size = max(1, round(font_pt / 72 * scale)) painter.save() painter.translate( spine_rect.center().x(), spine_rect.top() + (face_h - y_from_bottom) * scale, ) painter.rotate(90) font = QFont(family) font.setPixelSize(px_size) painter.setFont(font) painter.setPen(QColor(color)) text_rect = QRectF( -max_length_pt / 72 * scale / 2, -spine_rect.width() / 2, max_length_pt / 72 * scale, spine_rect.width(), ) text_length_px = min( text_rect.width(), QFontMetricsF(font).horizontalAdvance(text) ) painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, text) painter.restore() center_y = spine_rect.top() + (face_h - y_from_bottom) * scale hit_rect = QRectF( spine_rect.left() - 3, center_y - text_length_px / 2 - 5, spine_rect.width() + 6, text_length_px + 10, ) self._spine_text_hit_rects[role] = hit_rect if role == self._selected_spine_text: painter.setPen(QPen(QColor("#ffd35a"), 1.5, Qt.PenStyle.DashLine)) painter.setBrush(Qt.BrushStyle.NoBrush) painter.drawRect(hit_rect) def _front_text_path( self, role: str, ) -> tuple[QPainterPath, QRectF, QColor, float] | None: text = getattr(self.cover, f"front_{role}") if not text.strip(): return None front_rect = self._panel_rects["front"] base_size = getattr(self.cover, f"front_{role}_font_size_pt") scale = front_rect.width() / self.cover.face_width_in if self.cover.face_width_in else 1.0 font_px = max(1, round(base_size / 72 * scale)) font = QFont(self.cover.front_font_family) font.setPixelSize(font_px) path = QPainterPath() path.addText(QPointF(0, 0), font, text) bounds = path.boundingRect() max_width = front_rect.width() * 0.9 if bounds.width() > max_width: font_px = max(1, round(font_px * max_width / bounds.width())) font.setPixelSize(font_px) path = QPainterPath() path.addText(QPointF(0, 0), font, text) bounds = path.boundingRect() x_fraction = getattr(self.cover, f"front_{role}_x") y_fraction = getattr(self.cover, f"front_{role}_y") target = QPointF( front_rect.left() + x_fraction * front_rect.width(), front_rect.top() + y_fraction * front_rect.height(), ) path.translate(target.x() - bounds.center().x(), target.y() - bounds.center().y()) bounds = path.boundingRect() outline_radius = FRONT_COVER_OUTLINE_RADIUS_PX * scale / EXPORT_PPI hit_rect = bounds.adjusted( -outline_radius - 5, -outline_radius - 5, outline_radius + 5, outline_radius + 5, ) color = QColor(self.cover.title_color if role == "title" else self.cover.subtitle_color) return path, hit_rect, color, outline_radius def _paint_front_cover_text(self, painter: QPainter) -> None: self._front_text_hit_rects.clear() self._front_text_bounds.clear() for role in ("title", "subtitle"): text_layout = self._front_text_path(role) if text_layout is None: continue path, hit_rect, color, outline_radius = text_layout painter.setPen( QPen( QColor("#000000"), outline_radius * 2, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin, ) ) painter.setBrush(Qt.BrushStyle.NoBrush) painter.drawPath(path) painter.setPen(QPen(Qt.PenStyle.NoPen)) painter.setBrush(color) painter.drawPath(path) bounds = path.boundingRect() self._front_text_hit_rects[role] = hit_rect self._front_text_bounds[role] = bounds if role == self._selected_front_text: painter.setPen(QPen(QColor("#ffd35a"), 1.5, Qt.PenStyle.DashLine)) painter.setBrush(Qt.BrushStyle.NoBrush) painter.drawRect(hit_rect) 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._layout_rects() painter.fillRect(self._spread_rect, QColor("white")) spine_rect = self._panel_rects["spine"] spine_bleed_rect = QRectF( spine_rect.left(), self._spread_rect.top(), spine_rect.width(), self._spread_rect.height(), ) painter.fillRect(spine_bleed_rect, QColor("#263b50")) painter.setPen(QPen(QColor("#f2f3f5"), 1)) painter.drawRect(self._spread_rect) for side, label in (("back", "Back cover"), ("front", "Front cover")): rect = self._panel_rects[side] preview = self._previews.get(side) if preview is not None: top_band = QRectF(rect.left(), self._spread_rect.top(), rect.width(), rect.top() - self._spread_rect.top()) bottom_band = QRectF(rect.left(), rect.bottom(), rect.width(), self._spread_rect.bottom() - rect.bottom()) if top_band.height() > 0: painter.drawImage( top_band, preview, QRectF(0, 0, preview.width(), 1), ) if bottom_band.height() > 0: painter.drawImage( bottom_band, preview, QRectF(0, preview.height() - 1, preview.width(), 1), ) if side == "back" and rect.left() > self._spread_rect.left(): left_band = QRectF( self._spread_rect.left(), rect.top(), rect.left() - self._spread_rect.left(), rect.height() ) painter.drawImage(left_band, preview, QRectF(0, 0, 1, preview.height())) if top_band.height() > 0: painter.fillRect( QRectF(self._spread_rect.left(), top_band.top(), left_band.width(), top_band.height()), preview.pixelColor(0, 0), ) if bottom_band.height() > 0: painter.fillRect( QRectF(self._spread_rect.left(), bottom_band.top(), left_band.width(), bottom_band.height()), preview.pixelColor(0, preview.height() - 1), ) if side == "front" and rect.right() < self._spread_rect.right(): right_band = QRectF( rect.right(), rect.top(), self._spread_rect.right() - rect.right(), rect.height() ) painter.drawImage( right_band, preview, QRectF(preview.width() - 1, 0, 1, preview.height()), ) if top_band.height() > 0: painter.fillRect( QRectF(rect.right(), top_band.top(), right_band.width(), top_band.height()), preview.pixelColor(preview.width() - 1, 0), ) if bottom_band.height() > 0: painter.fillRect( QRectF(rect.right(), bottom_band.top(), right_band.width(), bottom_band.height()), preview.pixelColor(preview.width() - 1, preview.height() - 1), ) painter.fillRect(rect, QColor("white")) if preview is not None: painter.drawImage(rect, preview) else: painter.setPen(QColor("#6c727c")) painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, label) painter.setPen(QPen(QColor("#65b9db"), 1, Qt.PenStyle.DashLine)) painter.setBrush(Qt.BrushStyle.NoBrush) painter.drawRect(rect) painter.setPen(QPen(QColor("#d19cff"), 1, Qt.PenStyle.DashLine)) painter.drawRect(spine_rect) self._spine_text_hit_rects.clear() title_y, subtitle_y, title_extent, subtitle_extent = self.cover.spine_text_layout subtitle_zone_height = min(SPINE_SUBTITLE_ZONE_HEIGHT_IN, self.cover.face_height_in) subtitle_scale = spine_rect.height() / self.cover.face_height_in if self.cover.face_height_in else 1.0 subtitle_rect = QRectF( spine_rect.left(), spine_rect.bottom() - subtitle_zone_height * subtitle_scale, spine_rect.width(), subtitle_zone_height * subtitle_scale, ) painter.setPen(QPen(QColor("#ffc857"), 1, Qt.PenStyle.DashLine)) painter.drawRect(subtitle_rect) self._paint_spine_text( painter, "title", self.cover.title, title_y, title_extent, self.cover.title_font_family, self.cover.title_font_size_pt, self.cover.title_color, ) self._paint_spine_text( painter, "subtitle", self.cover.subtitle, subtitle_y, subtitle_extent, self.cover.subtitle_font_family, self.cover.subtitle_font_size_pt, self.cover.subtitle_color, ) self._paint_front_cover_text(painter) active = self._panel_rects.get(self.active_side) page = self.cover.front_page if self.active_side == "front" else self.cover.back_page if active and page: painter.setPen(QPen(QColor("#ffd35a"), 2)) painter.setBrush(Qt.BrushStyle.NoBrush) painter.drawRect(active) painter.setPen(QColor("#d1d5dc")) painter.drawText( QRectF(0, 6, self.width(), 24), Qt.AlignmentFlag.AlignCenter, "BACK COVER | SPINE | FRONT COVER", ) painter.end() def _spine_text_half_extent_in(self, role: str) -> float: text = getattr(self.cover, role) if not text.strip(): return 0.0 layout = self.cover.spine_text_layout max_length_pt = max(1.0, layout[2 if role == "title" else 3]) max_length_in = max_length_pt / 72 family = getattr(self.cover, f"{role}_font_family") size_pt = getattr(self.cover, f"{role}_font_size_pt") fitted_size = self._fitted_font_size(text, family, size_pt, max_length_pt) spine_rect = self._panel_rects["spine"] scale = spine_rect.width() / self.cover.spine_width_in if self.cover.spine_width_in else 1.0 font = QFont(family) font.setPixelSize(max(1, round(fitted_size / 72 * scale))) text_length_in = QFontMetricsF(font).horizontalAdvance(text) / max(scale, 1e-6) return min(max_length_in, text_length_in) / 2 def mousePressEvent(self, event) -> None: # noqa: N802 - Qt event name if event.button() == Qt.MouseButton.LeftButton: spine_rect = self._panel_rects.get("spine") if spine_rect is not None and spine_rect.contains(event.position()): point = event.position() hit_roles = [ role for role, hit_rect in self._spine_text_hit_rects.items() if hit_rect.contains(point) ] if hit_roles: role = min( hit_roles, key=lambda selected_role: abs( self._spine_text_hit_rects[selected_role].center().y() - point.y() ), ) layout = self.cover.spine_text_layout role_index = 0 if role == "title" else 1 other_role = "subtitle" if role == "title" else "title" self._selected_spine_text = role self._selected_front_text = None self._drag_spine_role = role self._drag_text_role = None self._drag_side = None self._drag_pos = point self._drag_start_spine_y = layout[role_index] self._drag_spine_half_extent_in = self._spine_text_half_extent_in(role) self._drag_other_spine_y = ( layout[1 - role_index] if getattr(self.cover, other_role).strip() else None ) self._drag_other_spine_half_extent_in = self._spine_text_half_extent_in( other_role ) self.setCursor(Qt.CursorShape.ClosedHandCursor) self.update() event.accept() return self._selected_spine_text = None self._selected_front_text = None self.setCursor(Qt.CursorShape.ArrowCursor) self.update() event.accept() return for side in ("back", "front"): rect = self._panel_rects.get(side) if rect is None or not rect.contains(event.position()): continue self.active_side = side self.activeSideChanged.emit(side) if side == "front": hit_roles = [ role for role, hit_rect in self._front_text_hit_rects.items() if hit_rect.contains(event.position()) ] if hit_roles: point = event.position() role = min( hit_roles, key=lambda selected_role: ( self._front_text_bounds[selected_role].center().x() - point.x() ) ** 2 + ( self._front_text_bounds[selected_role].center().y() - point.y() ) ** 2, ) self._selected_front_text = role self._selected_spine_text = None self._drag_text_role = role self._drag_pos = point self._drag_start_text_position = ( getattr(self.cover, f"front_{role}_x"), getattr(self.cover, f"front_{role}_y"), ) self.setCursor(Qt.CursorShape.ClosedHandCursor) self.update() event.accept() return self._selected_front_text = None self._selected_spine_text = None self._drag_spine_role = None page = self.cover.front_page if side == "front" else self.cover.back_page if page and page.image_path and side in self._previews: self._drag_pos = event.position() self._drag_side = side self._drag_text_role = None self._drag_start_pan = (page.pan_x, page.pan_y) self.setCursor(Qt.CursorShape.ClosedHandCursor) self.update() 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._drag_spine_role: role = self._drag_spine_role spine_rect = self._panel_rects["spine"] face_height = self.cover.face_height_in if spine_rect.height() > 0 and face_height > 0: point = event.position() scale = spine_rect.height() / face_height desired_y = self._drag_start_spine_y - ( point.y() - self._drag_pos.y() ) / scale zone_height = ( face_height if role == "title" else min(SPINE_SUBTITLE_ZONE_HEIGHT_IN, face_height) ) own_half = min(self._drag_spine_half_extent_in, zone_height / 2) low = own_half high = zone_height - own_half gap = 0.08 intervals = [(low, high)] if self._drag_other_spine_y is not None: safe_low = ( self._drag_other_spine_y - self._drag_other_spine_half_extent_in - own_half - gap ) safe_high = ( self._drag_other_spine_y + self._drag_other_spine_half_extent_in + own_half + gap ) intervals = [ (low, min(high, safe_low)), (max(low, safe_high), high), ] valid_intervals = [ interval for interval in intervals if interval[0] <= interval[1] ] if valid_intervals: new_y = min( ( max(start, min(end, desired_y)) for start, end in valid_intervals ), key=lambda candidate: abs(candidate - desired_y), ) setattr(self.cover, f"spine_{role}_y_in", new_y) self.update() self.changed.emit() event.accept() return if self._drag_pos is not None and self._drag_text_role: role = self._drag_text_role front_rect = self._panel_rects["front"] hit_rect = self._front_text_hit_rects.get(role) point = event.position() if hit_rect is not None and front_rect.width() > 0 and front_rect.height() > 0: min_x = min(0.5, hit_rect.width() / (2 * front_rect.width())) min_y = min(0.5, hit_rect.height() / (2 * front_rect.height())) x = self._drag_start_text_position[0] + ( point.x() - self._drag_pos.x() ) / front_rect.width() y = self._drag_start_text_position[1] + ( point.y() - self._drag_pos.y() ) / front_rect.height() setattr(self.cover, f"front_{role}_x", max(min_x, min(1 - min_x, x))) setattr(self.cover, f"front_{role}_y", max(min_y, min(1 - min_y, y))) self.update() self.changed.emit() event.accept() return if self._drag_pos is not None and self._drag_side and self._source_sizes.get(self._drag_side): side = self._drag_side page = self.cover.front_page if side == "front" else self.cover.back_page rect = self._panel_rects[side] preview = self._previews.get(side) if page is None or preview is None: return point = event.position() dx = point.x() - self._drag_pos.x() dy = point.y() - self._drag_pos.y() relative_x = rect.width() / max(1, preview.width()) relative_y = rect.height() / max(1, preview.height()) image_x, image_y, image_w, image_h, _ = _cover_geometry_for_ui( self._source_sizes[side], (preview.width(), preview.height()), page.zoom, self._drag_start_pan[0], self._drag_start_pan[1], ) denom_x = (image_w - preview.width()) / 2 denom_y = (image_h - preview.height()) / 2 page.pan_x = max( -1.0, min( self._drag_start_pan[0] + (dx / relative_x / denom_x if abs(denom_x) > 1e-6 else 0), 1.0, ), ) page.pan_y = max( -1.0, min( self._drag_start_pan[1] + (dy / relative_y / denom_y if abs(denom_y) > 1e-6 else 0), 1.0, ), ) self._render_previews() self.changed.emit() event.accept() return spine_rect = self._panel_rects.get("spine") spine_text_hovered = bool( spine_rect and spine_rect.contains(event.position()) and any( hit_rect.contains(event.position()) for hit_rect in self._spine_text_hit_rects.values() ) ) self.setCursor( Qt.CursorShape.OpenHandCursor if spine_text_hovered else Qt.CursorShape.ArrowCursor ) 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: photo_was_dragged = self._drag_side is not None self._drag_pos = None self._drag_side = None self._drag_text_role = None self._drag_spine_role = None self.setCursor(Qt.CursorShape.ArrowCursor) if photo_was_dragged: self.editFinished.emit() event.accept() return super().mouseReleaseEvent(event) 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: interior_path: Path | None = None include_cover = self.project.cover.has_content total_interior_pages = len(self.project.pages) total_work = total_interior_pages + (1 if include_cover else 0) try: interior_path = export_pdf( self.project, self.output_path, progress=lambda done, total: self.progressChanged.emit(done, total_work), ) output_paths = [interior_path] if include_cover: cover_path = interior_path.with_name(f"{interior_path.stem}-cover.pdf") output_paths.append( export_cover_pdf( self.project, cover_path, progress=lambda done, total: self.progressChanged.emit( total_interior_pages + done, total_work ), ) ) self.succeeded.emit("\n".join(str(path) for path in output_paths)) except Exception as exc: # The error is shown in the app so export failures are actionable. if interior_path is not None and include_cover: self.failed.emit( f"Interior PDF saved to:\n{interior_path}\n\nCover PDF export failed:\n{exc}" ) return self.failed.emit(str(exc)) class CoverExportWorker(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: output = export_cover_pdf( self.project, self.output_path, progress=lambda done, total: self.progressChanged.emit(done, total), ) self.succeeded.emit(str(output)) except Exception as exc: 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, known_paths: list[str] | None = None, profile: PrintProfile | None = None, ) -> None: super().__init__() self.folder = folder self.known_path_keys = {_photo_path_key(path) for path in (known_paths or [])} self.profile = copy.deepcopy(profile) if profile is not None else None self.is_rescan = known_paths is not None def run(self) -> None: try: if self.is_rescan: photos, issues = scan_photo_folder(self.folder) new_photos = [ (path, capture_date) for path, capture_date in photos if _photo_path_key(path) not in self.known_path_keys ] project = BookProject( photo_paths=[str(path) for path, _ in new_photos], pages=[PhotoPage(image_path=str(path)) for path, _ in new_photos], profile=self.profile or PrintProfile(), source_directory=str(Path(self.folder).resolve()), capture_dates={str(path): capture_date for path, capture_date in new_photos}, ) else: project, issues = BookProject.from_folder(self.folder) total = len(project.pages) self.stageChanged.emit( "Preparing new photo previews…" if self.is_rescan else "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 ProjectLoadWorker(QThread): stageChanged = Signal(str, int) progressChanged = Signal(int, int) completed = Signal(object, object, object) failed = Signal(str) def __init__(self, path: str) -> None: super().__init__() self.path = path def run(self) -> None: try: project = BookProject.load(self.path) pages = list(project.pages) pages.extend( page for page in (project.cover.back_page, project.cover.front_page) if page is not None ) total = len(pages) self.stageChanged.emit("Preparing project 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(pages, start=1): path = page.image_path if path and Path(path).is_file(): capture_date = photo_sort_date(path) if capture_date != datetime.max: photo_dates[path] = capture_date.strftime("%m/%d/%Y") try: preview = render_photo( path, (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) self.completed.emit(project, 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() _resolve_cover_font_families(self.project.cover) self.project_path: str | None = None self.settings = QSettings("Blurb", "Blurb Photo Book Editor") self._photo_date_cache: dict[str, str] = {} self._photo_thumbnail_cache: dict[int, QPixmap] = {} self.view_mode = "all" self.workspace_mode = "book" self._cover_controls_loading = False self.dirty = False self.current_page_index = -1 self.worker: ExportWorker | None = None self.cover_export_worker: CoverExportWorker | None = None self.folder_import_worker: FolderImportWorker | None = None self.import_progress_dialog: QProgressDialog | None = None self.project_load_worker: ProjectLoadWorker | None = None self.project_load_dialog: QProgressDialog | None = None self._pending_project_load_error: str | None = None self._pending_import_issues: list[str] = [] self._pending_import_error: str | None = None self._pending_empty_import = False self._pending_import_mode = "replace" self._pending_rescan_folder: str | None = None self._pending_rescan_count: int | None = None 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.cover_list = QListWidget() self.cover_list.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) self.cover_list.setDragDropMode(QAbstractItemView.DragDropMode.NoDragDrop) self.cover_list.setIconSize(QSize(110, 64)) self.cover_list.setMinimumHeight(48) self.cover_list.setMaximumHeight(152) self.canvas = PageCanvas() self.canvas.changed.connect(self._mark_dirty) self.canvas.editFinished.connect(self._selected_thumbnail_changed) self.cover_canvas = CoverCanvas() self.cover_canvas.changed.connect(self._cover_image_changed) self.cover_canvas.editFinished.connect(self._cover_image_edit_finished) self.cover_canvas.activeSideChanged.connect(self._cover_active_side_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) self.remove_back_cover_button = QPushButton("Remove back cover") self.remove_back_cover_button.clicked.connect(lambda: self._remove_cover("back")) self.remove_front_cover_button = QPushButton("Remove front cover") self.remove_front_cover_button.clicked.connect(lambda: self._remove_cover("front")) self.cover_title_edit = QLineEdit() self.cover_title_edit.setPlaceholderText("Title") self.cover_title_edit.textEdited.connect(self._cover_title_changed) self.cover_subtitle_edit = QLineEdit() self.cover_subtitle_edit.setPlaceholderText("Subtitle") self.cover_subtitle_edit.textEdited.connect(self._cover_subtitle_changed) self.front_title_edit = QLineEdit() self.front_title_edit.setPlaceholderText("Front cover title") self.front_title_edit.textEdited.connect(self._front_title_changed) self.front_subtitle_edit = QLineEdit() self.front_subtitle_edit.setPlaceholderText("Front cover subtitle") self.front_subtitle_edit.textEdited.connect(self._front_subtitle_changed) self.front_font_combo = QFontComboBox() self.front_font_combo.setToolTip("Font family shared by the front title and subtitle") self.front_font_combo.currentFontChanged.connect( lambda font: self._front_font_changed(font.family()) ) self.front_title_size_spin = self._front_font_size_spin(48.0) self.front_title_size_spin.setToolTip("Front cover title size") self.front_title_size_spin.valueChanged.connect( lambda value: self._front_font_size_changed("title", value) ) self.front_subtitle_size_spin = self._front_font_size_spin(24.0) self.front_subtitle_size_spin.setToolTip("Front cover subtitle size") self.front_subtitle_size_spin.valueChanged.connect( lambda value: self._front_font_size_changed("subtitle", value) ) self.cover_font_combos: dict[str, QFontComboBox] = {} self.cover_font_sizes: dict[str, QDoubleSpinBox] = {} self.cover_color_buttons: dict[str, QPushButton] = {} for role, default_size in (("title", 20.0), ("subtitle", 14.0)): font_combo = QFontComboBox() font_combo.setMinimumWidth(125) font_combo.setMaximumWidth(185) font_combo.setToolTip(f"{role.title()} font family") font_combo.currentFontChanged.connect( lambda font, selected_role=role: self._cover_font_changed( selected_role, font.family() ) ) font_size = self._cover_font_size_spin(default_size) font_size.setToolTip(f"{role.title()} font size") font_size.valueChanged.connect( lambda value, selected_role=role: self._cover_font_size_changed( selected_role, value ) ) color_button = QPushButton() color_button.setMaximumWidth(86) color_button.clicked.connect( lambda _checked=False, selected_role=role: self._choose_cover_color( selected_role ) ) self.cover_font_combos[role] = font_combo self.cover_font_sizes[role] = font_size self.cover_color_buttons[role] = color_button self.cover_face_width = self._cover_dimension_spin(10.074) self.cover_face_height = self._cover_dimension_spin(9.0) self.cover_spine_width = self._cover_dimension_spin(0.57) for spin in (self.cover_face_width, self.cover_face_height, self.cover_spine_width): spin.valueChanged.connect(self._cover_dimensions_changed) self.cover_margin_edits: dict[str, QLineEdit] = {} for side in ("left", "right", "top", "bottom"): edit = QLineEdit() edit.setPlaceholderText("inches") edit.editingFinished.connect( lambda selected_side=side: self._cover_margin_changed(selected_side) ) self.cover_margin_edits[side] = edit 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) self.cover_heading = QLabel("COVER PHOTOS") self.cover_heading.setStyleSheet("font-weight: bold; color: #d5d8de; padding-top: 4px;") left_layout.addWidget(self.cover_heading) left_layout.addWidget(self.cover_list) 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) self.editor_stack = QStackedWidget() book_workspace = QWidget() book_layout = QVBoxLayout(book_workspace) book_layout.setContentsMargins(0, 0, 0, 0) book_layout.addWidget(self.canvas, 1) self.editor_stack.addWidget(book_workspace) cover_workspace = QWidget() cover_layout = QVBoxLayout(cover_workspace) cover_layout.setContentsMargins(0, 0, 0, 0) cover_layout.addWidget(self.cover_canvas, 1) cover_form = QFormLayout() def cover_text_row(role: str, text_edit: QLineEdit) -> QWidget: row = QWidget() row_layout = QHBoxLayout(row) row_layout.setContentsMargins(0, 0, 0, 0) row_layout.setSpacing(6) row_layout.addWidget(text_edit, 1) row_layout.addWidget(self.cover_font_combos[role]) row_layout.addWidget(self.cover_font_sizes[role]) row_layout.addWidget(self.cover_color_buttons[role]) return row cover_form.addRow("Spine title", cover_text_row("title", self.cover_title_edit)) cover_form.addRow( "Spine subtitle", cover_text_row("subtitle", self.cover_subtitle_edit) ) cover_form.addRow("Front cover font", self.front_font_combo) def front_text_row(text_edit: QLineEdit, size_spin: QDoubleSpinBox) -> QWidget: row = QWidget() row_layout = QHBoxLayout(row) row_layout.setContentsMargins(0, 0, 0, 0) row_layout.setSpacing(6) row_layout.addWidget(text_edit, 1) row_layout.addWidget(size_spin) return row cover_form.addRow( "Front title", front_text_row(self.front_title_edit, self.front_title_size_spin) ) cover_form.addRow( "Front subtitle", front_text_row(self.front_subtitle_edit, self.front_subtitle_size_spin), ) def cover_field_pair(label_text: str, field: QWidget, max_width: int) -> QWidget: field.setMaximumWidth(max_width) pair = QWidget() pair_layout = QHBoxLayout(pair) pair_layout.setContentsMargins(0, 0, 0, 0) pair_layout.setSpacing(5) pair_layout.addWidget(QLabel(label_text)) pair_layout.addWidget(field) return pair dimensions_row = QWidget() dimensions_layout = QHBoxLayout(dimensions_row) dimensions_layout.setContentsMargins(0, 0, 0, 0) dimensions = ( ("Face width", self.cover_face_width), ("Face height", self.cover_face_height), ("Spine", self.cover_spine_width), ) for index, (label, spin) in enumerate(dimensions): if index: dimensions_layout.addStretch(1) dimensions_layout.addWidget(cover_field_pair(label, spin, 165)) cover_form.addRow("Dimensions", dimensions_row) margins_row = QWidget() margins_layout = QHBoxLayout(margins_row) margins_layout.setContentsMargins(0, 0, 0, 0) for index, side in enumerate(("left", "right", "top", "bottom")): if index: margins_layout.addStretch(1) margins_layout.addWidget( cover_field_pair(side.title(), self.cover_margin_edits[side], 135) ) cover_form.addRow("Wrap/bleed margins", margins_row) cover_layout.addLayout(cover_form) cover_margin_hint = QLabel( "Default margins are 0.000 in on each edge. Verify the exact wrap/bleed values for your Blurb format, paper, and page count." ) cover_margin_hint.setWordWrap(True) cover_margin_hint.setStyleSheet("color: #aab1bd; padding-left: 4px;") cover_layout.addWidget(cover_margin_hint) cover_button_row = QHBoxLayout() cover_button_row.addWidget(self.remove_back_cover_button) cover_button_row.addWidget(self.remove_front_cover_button) cover_button_row.addStretch(1) cover_layout.addLayout(cover_button_row) self.editor_stack.addWidget(cover_workspace) right = QWidget() right_layout = QVBoxLayout(right) right_layout.setContentsMargins(12, 10, 12, 10) right_layout.addWidget(self.editor_stack, 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) self.editor_hint = QLabel() self.editor_hint.setAlignment(Qt.AlignmentFlag.AlignCenter) self.editor_hint.setStyleSheet("color: #aab1bd; padding: 3px;") right_layout.addWidget(self.editor_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() self._refresh_cover_canvas() self._set_workspace("book") 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.rescan_folder_action = QAction("Rescan Folder for New Photos…", self) self.rescan_folder_action.triggered.connect(self._rescan_folder) file_menu.addAction(self.rescan_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.open_recent_menu = file_menu.addMenu("Open Recent") self.open_recent_menu.aboutToShow.connect(self._populate_recent_menu) self._populate_recent_menu() 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) self.export_cover_action = QAction("Export Cover PDF…", self) self.export_cover_action.triggered.connect(self._export_cover_pdf) file_menu.addAction(self.export_cover_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) view_menu.addSeparator() self.workspace_action_group = QActionGroup(self) self.workspace_action_group.setExclusive(True) self.book_editor_action = QAction("Book Editor", self) self.book_editor_action.setCheckable(True) self.book_editor_action.setChecked(True) self.book_editor_action.triggered.connect(lambda checked=False: self._set_workspace("book")) self.workspace_action_group.addAction(self.book_editor_action) view_menu.addAction(self.book_editor_action) self.cover_editor_action = QAction("Cover Editor", self) self.cover_editor_action.setCheckable(True) self.cover_editor_action.triggered.connect(lambda checked=False: self._set_workspace("cover")) self.workspace_action_group.addAction(self.cover_editor_action) view_menu.addAction(self.cover_editor_action) self.reset_spine_text_action = QAction("Reset spine text positions", self) self.reset_spine_text_action.setVisible(False) self.reset_spine_text_action.triggered.connect(self._reset_spine_text_positions) view_menu.addAction(self.reset_spine_text_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.cover_editor_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) @staticmethod def _cover_dimension_spin(value: float) -> QDoubleSpinBox: spin = QDoubleSpinBox() spin.setRange(0.001, 30.0) spin.setDecimals(3) spin.setSingleStep(0.125) spin.setSuffix(" in") spin.setValue(value) return spin @staticmethod def _cover_font_size_spin(value: float) -> QDoubleSpinBox: spin = QDoubleSpinBox() spin.setRange(4.0, 72.0) spin.setDecimals(1) spin.setSingleStep(1.0) spin.setSuffix(" pt") spin.setMaximumWidth(88) spin.setValue(value) return spin @staticmethod def _front_font_size_spin(value: float) -> QDoubleSpinBox: spin = QDoubleSpinBox() spin.setRange(4.0, 144.0) spin.setDecimals(1) spin.setSingleStep(1.0) spin.setSuffix(" pt") spin.setMaximumWidth(88) spin.setValue(value) return spin def _set_workspace(self, mode: str) -> None: self.workspace_mode = "cover" if mode == "cover" else "book" self.editor_stack.setCurrentIndex(1 if self.workspace_mode == "cover" else 0) self.book_editor_action.setChecked(self.workspace_mode == "book") self.cover_editor_action.setChecked(self.workspace_mode == "cover") self.reset_spine_text_action.setVisible(self.workspace_mode == "cover") self.editor_hint.setText( "Click a panel to select it; drag photos to crop them, front-cover text to position it, or spine text up/down. View → Reset spine text positions restores the original spine layout. Front text uses matching spine colors with a 7 px black outline; purple/gold dashed boxes mark the spine text areas." if self.workspace_mode == "cover" else "Drag inside the page to reposition the photo. Photos start cropped to fill the page." ) if self.workspace_mode == "cover": self._refresh_cover_canvas() else: self._refresh_canvas() self._sync_zoom_control() self._update_actions() def _reset_spine_text_positions(self) -> None: cover = self.project.cover if cover.spine_title_y_in is None and cover.spine_subtitle_y_in is None: return cover.spine_title_y_in = None cover.spine_subtitle_y_in = None self.cover_canvas.update() self.reset_spine_text_action.setEnabled(False) self._mark_dirty() def _refresh_cover_canvas(self) -> None: if hasattr(self, "cover_canvas"): self.cover_canvas.set_cover(self.project.cover) self.cover_canvas.set_active_side(self.cover_canvas.active_side) if hasattr(self, "cover_title_edit"): self._sync_cover_controls() if hasattr(self, "remove_back_cover_button"): self.remove_back_cover_button.setEnabled(self.project.cover.back_page is not None) self.remove_front_cover_button.setEnabled(self.project.cover.front_page is not None) if hasattr(self, "cover_list"): self._refresh_cover_list() def _refresh_cover_list(self) -> None: self.cover_list.clear() assigned = ( ("Front Cover", self.project.cover.front_page), ("Back Cover", self.project.cover.back_page), ) for label, page in assigned: if page is None: continue filename = Path(page.image_path).name if page.image_path else "Missing image" item = QListWidgetItem(f"[{label.upper()}] · {filename}") item.setData(Qt.ItemDataRole.UserRole, page) if page.image_path: item.setIcon(self._page_icon(page)) item.setToolTip(f"Used as the {label.lower()}") item.setSizeHint(QSize(300, 64)) self.cover_list.addItem(item) if not self.cover_list.count(): item = QListWidgetItem("No cover photos assigned") item.setFlags(Qt.ItemFlag.NoItemFlags) self.cover_list.addItem(item) def _sync_cover_controls(self) -> None: cover = self.project.cover self._cover_controls_loading = True for edit, value in ( (self.cover_title_edit, cover.title), (self.cover_subtitle_edit, cover.subtitle), (self.front_title_edit, cover.front_title), (self.front_subtitle_edit, cover.front_subtitle), ): edit.blockSignals(True) edit.setText(value) edit.blockSignals(False) self.front_font_combo.blockSignals(True) self.front_font_combo.setCurrentFont(QFont(cover.front_font_family)) self.front_font_combo.blockSignals(False) for spin, value in ( (self.front_title_size_spin, cover.front_title_font_size_pt), (self.front_subtitle_size_spin, cover.front_subtitle_font_size_pt), ): spin.blockSignals(True) spin.setValue(value) spin.blockSignals(False) for role in ("title", "subtitle"): family_combo = self.cover_font_combos[role] family_combo.blockSignals(True) family_combo.setCurrentFont(QFont(getattr(cover, f"{role}_font_family"))) family_combo.blockSignals(False) size_spin = self.cover_font_sizes[role] size_spin.blockSignals(True) size_spin.setValue(getattr(cover, f"{role}_font_size_pt")) size_spin.blockSignals(False) self._set_cover_color_button(role, getattr(cover, f"{role}_color")) for spin, value in ( (self.cover_face_width, cover.face_width_in), (self.cover_face_height, cover.face_height_in), (self.cover_spine_width, cover.spine_width_in), ): spin.blockSignals(True) spin.setValue(value) spin.blockSignals(False) for side, edit in self.cover_margin_edits.items(): value = getattr(cover, f"margin_{side}_in") edit.blockSignals(True) edit.setText("" if value is None else f"{value:g}") edit.blockSignals(False) self._cover_controls_loading = False def _cover_title_changed(self, text: str) -> None: if self._cover_controls_loading: return self.project.cover.title = text self.cover_canvas.update() self._mark_dirty() def _cover_subtitle_changed(self, text: str) -> None: if self._cover_controls_loading: return self.project.cover.subtitle = text self.cover_canvas.update() self._mark_dirty() def _front_title_changed(self, text: str) -> None: if self._cover_controls_loading: return self.project.cover.front_title = text self.cover_canvas.update() self._mark_dirty() def _front_subtitle_changed(self, text: str) -> None: if self._cover_controls_loading: return self.project.cover.front_subtitle = text self.cover_canvas.update() self._mark_dirty() def _front_font_changed(self, family: str) -> None: if self._cover_controls_loading: return self.project.cover.front_font_family = family self.cover_canvas.update() self._mark_dirty() def _front_font_size_changed(self, role: str, size_pt: float) -> None: if self._cover_controls_loading: return setattr(self.project.cover, f"front_{role}_font_size_pt", size_pt) self.cover_canvas.update() self._mark_dirty() def _cover_font_changed(self, role: str, family: str) -> None: if self._cover_controls_loading: return setattr(self.project.cover, f"{role}_font_family", family) self.cover_canvas.update() self._mark_dirty() def _cover_font_size_changed(self, role: str, size_pt: float) -> None: if self._cover_controls_loading: return setattr(self.project.cover, f"{role}_font_size_pt", size_pt) self.cover_canvas.update() self._mark_dirty() def _set_cover_color_button(self, role: str, color: str) -> None: button = self.cover_color_buttons[role] selected = QColor(color) if not selected.isValid(): selected = QColor("#fff03f") ink = "#111111" if selected.lightness() > 145 else "#ffffff" button.setText(selected.name().upper()) button.setToolTip(f"{role.title()} text color — click to choose") button.setStyleSheet( f"QPushButton {{ background-color: {selected.name()}; color: {ink}; }}" ) def _choose_cover_color(self, role: str) -> None: current = getattr(self.project.cover, f"{role}_color") selected = QColorDialog.getColor(QColor(current), self, f"{role.title()} text color") if not selected.isValid(): return color = selected.name() setattr(self.project.cover, f"{role}_color", color) self._set_cover_color_button(role, color) self.cover_canvas.update() self._mark_dirty() def _cover_dimensions_changed(self, _value: float) -> None: if self._cover_controls_loading: return self.project.cover.face_width_in = self.cover_face_width.value() self.project.cover.face_height_in = self.cover_face_height.value() self.project.cover.spine_width_in = self.cover_spine_width.value() self.cover_canvas.set_cover(self.project.cover) self._sync_zoom_control() self._mark_dirty() def _cover_margin_changed(self, side: str) -> None: if self._cover_controls_loading: return edit = self.cover_margin_edits[side] value_text = edit.text().strip() value: float | None = None if value_text: try: value = float(value_text) except ValueError: QMessageBox.warning(self, "Cover setup", f"Enter a numeric {side} margin in inches.") self._sync_cover_controls() return if not 0 <= value <= 30: QMessageBox.warning(self, "Cover setup", f"The {side} margin must be between 0 and 30 inches.") self._sync_cover_controls() return setattr(self.project.cover, f"margin_{side}_in", value) self.cover_canvas.set_cover(self.project.cover) self._mark_dirty() def _active_cover_page(self) -> PhotoPage | None: return ( self.project.cover.front_page if self.cover_canvas.active_side == "front" else self.project.cover.back_page ) def _cover_active_side_changed(self, side: str) -> None: self.cover_canvas.set_active_side(side) self._sync_zoom_control() self._update_actions() def _cover_image_changed(self) -> None: self.reset_spine_text_action.setEnabled( self.project.cover.spine_title_y_in is not None or self.project.cover.spine_subtitle_y_in is not None ) self._mark_dirty() def _cover_image_edit_finished(self) -> None: page = self._active_cover_page() if page is not None: self._photo_thumbnail_cache.pop(id(page), None) self._refresh_cover_list() self._sync_zoom_control() self._mark_dirty() 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" cover_text = "" if self.project.cover.has_content: cover_text = ( " · cover setup needed" if self.project.cover.validate() else " · cover setup ready" ) self.status_label.setText( f"{count} pages · {photo_count} photo pages · {setup}{odd}{missing_text}{cover_text}" ) def _update_actions(self) -> None: self._sync_view_menu() self.reset_spine_text_action.setEnabled( self.project.cover.spine_title_y_in is not None or self.project.cover.spine_subtitle_y_in is not None ) 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) zoom_page = self._active_cover_page() if self.workspace_mode == "cover" else selected self.zoom_slider.setEnabled( bool(zoom_page and zoom_page.image_path and Path(zoom_page.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._refresh_cover_list() self._sync_cover_controls() self.cover_canvas.set_cover(self.project.cover) self.remove_back_cover_button.setEnabled(self.project.cover.back_page is not None) self.remove_front_cover_button.setEnabled(self.project.cover.front_page is not None) 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: page = self._active_cover_page() if self.workspace_mode == "cover" else self._page_at_row( self.pages_list.currentRow() ) 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 if self.workspace_mode == "cover": width, height = self.project.cover.face_width_in, self.project.cover.face_height_in else: 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: page = self._active_cover_page() if self.workspace_mode == "cover" else self._page_at_row( self.pages_list.currentRow() ) if page is None: return if not page.image_path or not Path(page.image_path).is_file(): return if self.workspace_mode == "cover": width, height = self.project.cover.face_width_in, self.project.cover.face_height_in else: 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") if self.workspace_mode == "cover": self.cover_canvas.set_cover(self.project.cover) else: self.canvas.set_page( page, self.project.profile, self._project_index_for_page(page), len(self.project.pages), ) self._mark_dirty() if self.workspace_mode == "cover": self._cover_image_edit_finished() else: self._refresh_one_page(self.pages_list.currentRow(), refresh_thumbnail=True) def _step_zoom(self, factor: float) -> None: page = self._active_cover_page() if self.workspace_mode == "cover" else self._page_at_row( self.pages_list.currentRow() ) if page is None: return if not page.image_path or not Path(page.image_path).is_file(): return if self.workspace_mode == "cover": width, height = self.project.cover.face_width_in, self.project.cover.face_height_in else: 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() if self.workspace_mode == "cover": self.cover_canvas.set_cover(self.project.cover) else: self.canvas.set_page( page, self.project.profile, self._project_index_for_page(page), len(self.project.pages), ) self._mark_dirty() if self.workspace_mode == "cover": self._cover_image_edit_finished() else: self._refresh_one_page(self.pages_list.currentRow(), 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)) if page.image_path: menu.addSeparator() front_action = menu.addAction("Make Front Cover") front_action.setEnabled(Path(page.image_path).is_file()) front_action.triggered.connect( lambda checked=False, selected_page=page: self._assign_cover("front", selected_page) ) back_action = menu.addAction("Make Back Cover") back_action.setEnabled(Path(page.image_path).is_file()) back_action.triggered.connect( lambda checked=False, selected_page=page: self._assign_cover("back", selected_page) ) menu.exec(self.pages_list.mapToGlobal(position)) @staticmethod def _chronological_key(page: PhotoPage) -> tuple[datetime, str, str]: path = page.image_path or "" return photo_sort_date(path), Path(path).name.casefold(), path.casefold() def _insert_page_chronologically(self, page: PhotoPage) -> int: if not page.image_path: self.project.pages.append(page) return len(self.project.pages) - 1 target_key = self._chronological_key(page) for index, existing in enumerate(self.project.pages): if existing.image_path and self._chronological_key(existing) > target_key: self.project.pages.insert(index, page) return index self.project.pages.append(page) return len(self.project.pages) - 1 def _assign_cover(self, side: str, page: PhotoPage) -> None: if side not in ("front", "back") or not page.image_path: return index = self._project_index_for_page(page) if index < 0: return del self.project.pages[index] slot_name = f"{side}_page" previous_page = getattr(self.project.cover, slot_name) setattr(self.project.cover, slot_name, page) self.cover_canvas.set_active_side(side) if previous_page is not None: self._insert_page_chronologically(previous_page) self._photo_thumbnail_cache.pop(id(page), None) self._set_dirty(True) self._refresh_page_list(min(index, len(self.project.pages) - 1) if self.project.pages else None) def _remove_cover(self, side: str) -> None: if side not in ("front", "back"): return slot_name = f"{side}_page" page = getattr(self.project.cover, slot_name) if page is None: return setattr(self.project.cover, slot_name, None) index = self._insert_page_chronologically(page) self._set_dirty(True) self._refresh_page_list(index) 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._start_folder_scan(folder, "replace") def _rescan_folder(self) -> None: if self.folder_import_worker and self.folder_import_worker.isRunning(): return starting_folder = self.project.source_directory or str(Path.home()) folder = QFileDialog.getExistingDirectory( self, "Rescan photo folder for new images", starting_folder, ) if not folder: return known_paths = [page.image_path for page in self.project.pages if page.image_path] for page in (self.project.cover.back_page, self.project.cover.front_page): if page and page.image_path: known_paths.append(page.image_path) self._start_folder_scan(folder, "rescan", known_paths) def _start_folder_scan( self, folder: str, mode: str, known_paths: list[str] | None = None, ) -> None: self._pending_import_issues = [] self._pending_import_error = None self._pending_empty_import = False self._pending_import_mode = mode self._pending_rescan_folder = folder if mode == "rescan" else None self._pending_rescan_count = None is_rescan = mode == "rescan" dialog = QProgressDialog( "Scanning for new photos…" if is_rescan else "Scanning photo folder…", "", 0, 0, self, ) dialog.setWindowTitle("Rescanning photos" if is_rescan else "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, known_paths=known_paths, profile=self.project.profile if is_rescan else None, ) 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: phase = ( "Preparing new photo previews" if self._pending_import_mode == "rescan" else "Preparing photo previews" ) self.import_progress_dialog.setLabelText( f"{phase}… {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 self._pending_import_mode == "rescan": selected_page = self._page_at_row(self.pages_list.currentRow()) photo_path_keys = { _photo_path_key(path) for path in self.project.photo_paths } known_keys = { _photo_path_key(page.image_path) for page in self.project.pages if page.image_path } for page in (self.project.cover.back_page, self.project.cover.front_page): if page and page.image_path: known_keys.add(_photo_path_key(page.image_path)) first_inserted_index: int | None = None added_count = 0 for page in project.pages: if not page.image_path: continue path_key = _photo_path_key(page.image_path) if path_key in known_keys: continue known_keys.add(path_key) if path_key not in photo_path_keys: self.project.photo_paths.append(page.image_path) photo_path_keys.add(path_key) index = self._insert_page_chronologically(page) if first_inserted_index is None or index < first_inserted_index: first_inserted_index = index added_count += 1 if id(page) in thumbnails: self._photo_thumbnail_cache[id(page)] = QPixmap.fromImage(thumbnails[id(page)]) self._photo_date_cache.update(photo_dates) new_source = self._pending_rescan_folder or project.source_directory source_changed = bool(new_source and self.project.source_directory != new_source) if new_source: self.project.source_directory = new_source self._pending_rescan_count = added_count if added_count or source_changed: self._set_dirty(True) if added_count: selected_index = ( self._project_index_for_page(selected_page) if selected_page is not None else first_inserted_index ) self._refresh_page_list(selected_index) elif source_changed: self._update_status() image_word = "image" if added_count == 1 else "images" self.status_label.setText( f"Rescan complete: imported {added_count} new {image_word}." ) return if not project.photo_paths: self._pending_empty_import = True return self.project = project _resolve_cover_font_families(self.project.cover) 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_import_mode == "rescan": count = self._pending_rescan_count or 0 image_word = "image" if count == 1 else "images" QMessageBox.information( self, "Rescan complete", f"Imported {count} new {image_word} into the book.", ) 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) if ( self._pending_import_mode == "rescan" and self._pending_import_issues and not self._pending_import_error ): 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 self._pending_import_mode = "replace" self._pending_rescan_folder = None self._pending_rescan_count = None def _recent_project_paths(self) -> list[str]: stored = self.settings.value("recentProjects", []) if isinstance(stored, str): candidates = [stored] elif isinstance(stored, (list, tuple)): candidates = [str(path) for path in stored] else: candidates = [] paths: list[str] = [] seen: set[str] = set() for candidate in candidates: try: normalized = str(Path(candidate).expanduser().resolve()) except OSError: normalized = candidate key = normalized.casefold() if normalized and key not in seen: paths.append(normalized) seen.add(key) return paths def _remember_project(self, path: str) -> None: normalized = str(Path(path).expanduser().resolve()) updated = [normalized] updated.extend( recent for recent in self._recent_project_paths() if recent.casefold() != normalized.casefold() ) self.settings.setValue("recentProjects", updated[:10]) self._populate_recent_menu() def _populate_recent_menu(self) -> None: menu = self.open_recent_menu menu.clear() recent = self._recent_project_paths() if not recent: empty_action = menu.addAction("No recent projects") empty_action.setEnabled(False) else: name_counts: dict[str, int] = {} for path in recent: name = Path(path).name.casefold() name_counts[name] = name_counts.get(name, 0) + 1 for path in recent: project_path = Path(path) label = project_path.name if name_counts.get(label.casefold(), 0) > 1: label = f"{label} — {project_path.parent}" if not project_path.is_file(): label += " (missing)" action = menu.addAction(label) action.setToolTip(path) action.setEnabled(project_path.is_file()) action.triggered.connect( lambda _checked=False, selected_path=path: self._open_recent_project( selected_path ) ) menu.addSeparator() clear_action = menu.addAction("Clear Recent") clear_action.setEnabled(bool(recent)) clear_action.triggered.connect(self._clear_recent_projects) def _clear_recent_projects(self) -> None: self.settings.setValue("recentProjects", []) self._populate_recent_menu() def _load_project(self, path: str) -> bool: if ( self.project_load_worker and self.project_load_worker.isRunning() ) or (self.folder_import_worker and self.folder_import_worker.isRunning()): return False self._pending_project_load_error = None dialog = QProgressDialog("Loading project…", "", 0, 0, self) dialog.setWindowTitle("Opening project") 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.project_load_dialog = dialog worker = ProjectLoadWorker(path) self.project_load_worker = worker worker.stageChanged.connect(self._project_load_stage_changed) worker.progressChanged.connect(self._project_load_progress_changed) worker.completed.connect(self._project_load_completed) worker.failed.connect(self._project_load_failed) worker.finished.connect(self._project_load_worker_finished) dialog.show() worker.start() return True def _project_load_stage_changed(self, message: str, total: int) -> None: if not self.project_load_dialog: return self.project_load_dialog.setLabelText(message) self.project_load_dialog.setRange(0, total) self.project_load_dialog.setValue(0) def _project_load_progress_changed(self, completed: int, total: int) -> None: if self.project_load_dialog: self.project_load_dialog.setLabelText( f"Preparing project photo previews… {completed} of {total}" ) self.project_load_dialog.setValue(completed) def _project_load_completed( self, project: BookProject, photo_dates: dict[str, str], thumbnails: dict[int, QImage], ) -> None: self.project = project _resolve_cover_font_families(self.project.cover) self.project_path = str(Path(self.project_load_worker.path).expanduser().resolve()) 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(False) self._refresh_page_list(0) self._remember_project(self.project_path) def _project_load_failed(self, message: str) -> None: self._pending_project_load_error = message def _project_load_worker_finished(self) -> None: worker = self.project_load_worker self.project_load_worker = None if self.project_load_dialog: self.project_load_dialog.close() self.project_load_dialog.deleteLater() self.project_load_dialog = None if worker: worker.deleteLater() if self._pending_project_load_error: QMessageBox.critical( self, "Open project", f"Could not open the project:\n{self._pending_project_load_error}", ) self._pending_project_load_error = None 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 path: self._load_project(path) def _open_recent_project(self, path: str) -> None: if self._maybe_save(): self._load_project(path) 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._remember_project(self.project_path) 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()) or ( self.cover_export_worker and self.cover_export_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 if self.project.cover.has_content: cover_errors = self.project.cover.validate() if cover_errors: QMessageBox.warning(self, "Cover setup", "\n".join(cover_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" if self.project.cover.has_content: interior_destination = Path(output) cover_destination = interior_destination.with_name( f"{interior_destination.stem}-cover.pdf" ) if cover_destination.exists(): overwrite = QMessageBox.question( self, "Replace cover PDF", f"The companion cover PDF already exists:\n{cover_destination}\n\nReplace it?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) if overwrite != QMessageBox.StandardButton.Yes: return 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.export_cover_action.setEnabled(False) total_work = len(snapshot.pages) + (1 if snapshot.cover.has_content else 0) self.progress.setRange(0, total_work) self.progress.setValue(0) self.progress.setVisible(True) self.status_label.setText("Rendering pages…") def _export_cover_pdf(self) -> None: if (self.worker and self.worker.isRunning()) or ( self.cover_export_worker and self.cover_export_worker.isRunning() ): return if not self.project.cover.has_content: QMessageBox.information( self, "Export cover PDF", "Add a cover photo or spine text before exporting the cover.", ) return cover_errors = self.project.cover.validate() if cover_errors: QMessageBox.warning(self, "Cover setup", "\n".join(cover_errors)) return project_path = Path(self.project_path) if self.project_path else None output_directory = ( project_path.parent if project_path else Path(self.project.source_directory or Path.home()) ) base_name = project_path.stem if project_path else "blurb-book" suggested = output_directory / f"{base_name}-cover.pdf" output, _ = QFileDialog.getSaveFileName( self, "Export Blurb Cover PDF", str(suggested), "PDF files (*.pdf)", ) if not output: return if not output.lower().endswith(".pdf"): output += ".pdf" destination = Path(output) if destination.exists(): overwrite = QMessageBox.question( self, "Replace cover PDF", f"The cover PDF already exists:\n{destination}\n\nReplace it?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) if overwrite != QMessageBox.StandardButton.Yes: return snapshot = copy.deepcopy(self.project) self.cover_export_worker = CoverExportWorker(snapshot, output) self.cover_export_worker.progressChanged.connect(self._cover_export_progress) self.cover_export_worker.succeeded.connect(self._cover_export_succeeded) self.cover_export_worker.failed.connect(self._cover_export_failed) self.cover_export_worker.start() self.export_action.setEnabled(False) self.export_cover_action.setEnabled(False) self.progress.setRange(0, 0) self.progress.setVisible(True) self.status_label.setText("Rendering cover spread…") def _cover_export_progress(self, done: int, total: int) -> None: self.progress.setRange(0, total) self.progress.setValue(done) self.status_label.setText("Cover PDF verified and saved.") def _cover_export_succeeded(self, path: str) -> None: self.export_action.setEnabled(True) self.export_cover_action.setEnabled(True) self.progress.setVisible(False) self.status_label.setText(f"Cover PDF verified and saved: {path}") QMessageBox.information(self, "Cover PDF ready", f"Cover PDF created and verified:\n\n{path}") def _cover_export_failed(self, message: str) -> None: self.export_action.setEnabled(True) self.export_cover_action.setEnabled(True) self.progress.setVisible(False) self.status_label.setText("Cover PDF export failed.") QMessageBox.critical(self, "Cover PDF export failed", message) def _export_progress(self, done: int, total: int) -> None: self.progress.setRange(0, total) self.progress.setValue(done) if done > len(self.project.pages): self.status_label.setText("Verifying cover spread…") else: self.status_label.setText(f"Rendering page {done} of {len(self.project.pages)}…") def _export_succeeded(self, path: str) -> None: self.export_action.setEnabled(True) self.export_cover_action.setEnabled(True) self.progress.setVisible(False) paths = path.splitlines() self.status_label.setText(f"PDF verified and saved: {paths[0]}") QMessageBox.information(self, "PDF ready", "PDF created and verified:\n\n" + "\n".join(paths)) def _export_failed(self, message: str) -> None: self.export_action.setEnabled(True) self.export_cover_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.project_load_worker and self.project_load_worker.isRunning(): QMessageBox.information( self, "Project loading", "Wait for the project to finish loading before closing.", ) event.ignore() return 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.cover_export_worker and self.cover_export_worker.isRunning(): QMessageBox.information( self, "Cover PDF export running", "Wait for the cover 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()