- Implemented the main function to launch the editor. - Added error handling for missing PySide6 dependency. - Included instructions for installing required packages.
1360 lines
55 KiB
Python
1360 lines
55 KiB
Python
"""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()
|