Files
blurb/blurb_editor.py
T
ryan 4d5d3a4c75 Add main entry point for Blurb Photo Book Editor
- Implemented the main function to launch the editor.
- Added error handling for missing PySide6 dependency.
- Included instructions for installing required packages.
2026-10-05 00:11:30 -04:00

1360 lines
55 KiB
Python
Raw Blame History

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