Files
blurb/blurb_editor.py
T

3144 lines
132 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, QPointF, QSettings
from PySide6.QtGui import QAction, QActionGroup, QColor, QFont, QFontDatabase, QFontMetricsF, QImage, QPainter, QPainterPath, QPen, QPixmap
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QColorDialog,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFileDialog,
QFormLayout,
QFontComboBox,
QHBoxLayout,
QLabel,
QMainWindow,
QMessageBox,
QMenu,
QProgressBar,
QProgressDialog,
QPushButton,
QSlider,
QSpinBox,
QSplitter,
QStackedWidget,
QToolBar,
QToolButton,
QVBoxLayout,
QWidget,
QListWidget,
QListWidgetItem,
QLineEdit,
QStatusBar,
)
from blurb_book import (
BookProject,
CoverLayout,
EXPORT_PPI,
FRONT_COVER_OUTLINE_RADIUS_PX,
PhotoPage,
PrintProfile,
SUPPORTED_EXTENSIONS,
SPINE_SUBTITLE_ZONE_HEIGHT_IN,
export_cover_pdf,
export_pdf,
pan_for_centered_zoom,
page_zoom_bounds,
photo_sort_date,
render_photo,
scan_photo_folder,
)
def _pil_to_qimage(image: Image.Image) -> QImage:
rgb = image.convert("RGB")
data = rgb.tobytes("raw", "RGB")
return QImage(data, rgb.width, rgb.height, rgb.width * 3, QImage.Format.Format_RGB888).copy()
def _page_dimensions(profile: PrintProfile) -> tuple[float, float]:
return (
profile.pdf_width_in or profile.trim_width_in,
profile.pdf_height_in or profile.trim_height_in,
)
def _photo_size(path: str) -> tuple[int, int]:
with Image.open(path) as source:
image = ImageOps.exif_transpose(source)
return image.size
def _photo_path_key(path: str | Path) -> str:
value = str(path)
try:
value = str(Path(value).expanduser().resolve())
except (OSError, RuntimeError, ValueError):
pass
return value.casefold()
def _resolve_cover_font_families(cover: CoverLayout) -> None:
available = {family.casefold(): family for family in QFontDatabase.families()}
fallback = available.get("arial", "Sans Serif")
for attribute in (
"front_font_family",
"title_font_family",
"subtitle_font_family",
):
family = getattr(cover, attribute)
setattr(cover, attribute, available.get(family.casefold(), fallback))
class PageListWidget(QListWidget):
orderChanged = Signal()
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
self.setDefaultDropAction(Qt.DropAction.MoveAction)
self.setDragDropOverwriteMode(False)
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.setWordWrap(True)
self.setSpacing(5)
self.setIconSize(QSize(150, 126))
self.setMinimumWidth(330)
self.setMaximumWidth(390)
def dropEvent(self, event) -> None: # noqa: N802 - Qt event name
super().dropEvent(event)
self.orderChanged.emit()
class PageCanvas(QWidget):
changed = Signal()
editFinished = Signal()
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setMinimumSize(520, 420)
self.setMouseTracking(True)
self.setCursor(Qt.CursorShape.ArrowCursor)
self.page: PhotoPage | None = None
self.profile = PrintProfile()
self.page_index = 0
self.page_count = 0
self._preview: QImage | None = None
self._error: str | None = None
self._page_rect = QRectF()
self._drag_pos = None
self._drag_start_pan = (0.0, 0.0)
self._source_size: tuple[int, int] | None = None
def set_page(self, page: PhotoPage | None, profile: PrintProfile, index: int, count: int) -> None:
self.page = page
self.profile = profile
self.page_index = index
self.page_count = count
self._source_size = None
self._render_preview()
def set_profile(self, profile: PrintProfile) -> None:
self.profile = profile
self._render_preview()
def _render_preview(self) -> None:
self._preview = None
self._error = None
if self.page and self.page.image_path:
try:
page_w, page_h = _page_dimensions(self.profile)
aspect = page_w / page_h
if self.width() > 0 and self.height() > 0:
available_w = max(100, self.width() - 50)
available_h = max(100, self.height() - 74)
if available_w / available_h > aspect:
preview_h = available_h
preview_w = round(preview_h * aspect)
else:
preview_w = available_w
preview_h = round(preview_w / aspect)
preview_w, preview_h = min(preview_w, 1800), min(preview_h, 1800)
rendered = render_photo(
self.page.image_path,
(max(1, preview_w), max(1, preview_h)),
self.page.zoom,
self.page.pan_x,
self.page.pan_y,
preview=True,
)
self._preview = _pil_to_qimage(rendered)
rendered.close()
if self._source_size is None:
self._source_size = _photo_size(self.page.image_path)
except (OSError, ValueError) as exc:
self._error = f"Photo could not be opened:\n{exc}"
self.update()
def resizeEvent(self, event) -> None: # noqa: N802 - Qt event name
super().resizeEvent(event)
self._render_preview()
def _fit_page_rect(self) -> QRectF:
page_w, page_h = _page_dimensions(self.profile)
aspect = page_w / page_h
usable_w = max(100.0, self.width() - 48.0)
usable_h = max(100.0, self.height() - 72.0)
if usable_w / usable_h > aspect:
height = usable_h
width = height * aspect
else:
width = usable_w
height = width / aspect
return QRectF((self.width() - width) / 2, (self.height() - height) / 2 + 9, width, height)
def paintEvent(self, event) -> None: # noqa: N802 - Qt event name
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.fillRect(self.rect(), QColor("#252932"))
self._page_rect = self._fit_page_rect()
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor("#111319"))
shadow = self._page_rect.adjusted(5, 5, 5, 5)
painter.drawRoundedRect(shadow, 2, 2)
painter.fillRect(self._page_rect, QColor("white"))
if self._preview is not None:
painter.drawImage(self._page_rect, self._preview)
elif self.page and self.page.image_path:
painter.setPen(QColor("#a5aab3"))
painter.drawText(self._page_rect, Qt.AlignmentFlag.AlignCenter, self._error or "Loading photo…")
else:
painter.setPen(QColor("#7a808b"))
painter.drawText(self._page_rect, Qt.AlignmentFlag.AlignCenter, "Blank page")
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.setPen(QPen(QColor("#e4e6eb"), 1))
painter.drawRect(self._page_rect)
profile = self.profile
pdf_w, pdf_h = _page_dimensions(profile)
# PDF page 1 is right-hand. Its binding edge is the left edge; on
# alternating left-hand pages, the binding is on the right.
trim_left_in = (
profile.trim_binding_in if self.page_index % 2 == 0 else profile.trim_outside_in
)
trim_x = self._page_rect.left() + self._page_rect.width() * trim_left_in / pdf_w
trim_y = self._page_rect.top() + self._page_rect.height() * (
1.0 - (profile.trim_bottom_in + profile.trim_height_in) / pdf_h
)
trim_w = self._page_rect.width() * profile.trim_width_in / pdf_w
trim_h = self._page_rect.height() * profile.trim_height_in / pdf_h
trim_rect = QRectF(trim_x, trim_y, trim_w, trim_h)
# If trim size equals the displayed page, the guide lands on top of the
# page outline. Move it just inside the edge so the dashed guide stays
# visible even before bleed dimensions are configured.
trim_rect = trim_rect.adjusted(
2 if abs(trim_rect.left() - self._page_rect.left()) < 2 else 0,
2 if abs(trim_rect.top() - self._page_rect.top()) < 2 else 0,
-2 if abs(trim_rect.right() - self._page_rect.right()) < 2 else 0,
-2 if abs(trim_rect.bottom() - self._page_rect.bottom()) < 2 else 0,
)
painter.setPen(QPen(QColor("#ffc94f"), 2, Qt.PenStyle.DashLine))
painter.drawRect(trim_rect)
painter.setPen(QColor("#d1d5dc"))
painter.drawText(
QRectF(0, 10, self.width(), 26),
Qt.AlignmentFlag.AlignCenter,
f"Page {self.page_index + 1} of {self.page_count} · dashed line: trim area",
)
painter.end()
def mousePressEvent(self, event) -> None: # noqa: N802 - Qt event name
if (
event.button() == Qt.MouseButton.LeftButton
and self.page
and self.page.image_path
and self._preview is not None
and self._page_rect.contains(event.position())
):
self._drag_pos = event.position()
self._drag_start_pan = (self.page.pan_x, self.page.pan_y)
self.setCursor(Qt.CursorShape.ClosedHandCursor)
event.accept()
return
super().mousePressEvent(event)
def mouseMoveEvent(self, event) -> None: # noqa: N802 - Qt event name
if self._drag_pos is not None and self.page and self._source_size:
point = event.position()
dx = point.x() - self._drag_pos.x()
dy = point.y() - self._drag_pos.y()
relative_x = self._page_rect.width() / max(1, self._preview.width())
relative_y = self._page_rect.height() / max(1, self._preview.height())
image_x, image_y, image_w, image_h, _ = _cover_geometry_for_ui(
self._source_size,
(self._preview.width(), self._preview.height()),
self.page.zoom,
self._drag_start_pan[0],
self._drag_start_pan[1],
)
denom_x = (image_w - self._preview.width()) / 2
denom_y = (image_h - self._preview.height()) / 2
pan_x = self._drag_start_pan[0] + (dx / relative_x / denom_x if abs(denom_x) > 1e-6 else 0)
pan_y = self._drag_start_pan[1] + (dy / relative_y / denom_y if abs(denom_y) > 1e-6 else 0)
self.page.pan_x = max(-1.0, min(pan_x, 1.0))
self.page.pan_y = max(-1.0, min(pan_y, 1.0))
self._render_preview()
self.changed.emit()
event.accept()
return
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event) -> None: # noqa: N802 - Qt event name
if event.button() == Qt.MouseButton.LeftButton and self._drag_pos is not None:
self._drag_pos = None
self.setCursor(Qt.CursorShape.ArrowCursor)
self.editFinished.emit()
event.accept()
return
super().mouseReleaseEvent(event)
def _cover_geometry_for_ui(source_size, page_size, zoom, pan_x, pan_y):
source_w, source_h = source_size
page_w, page_h = page_size
cover = max(page_w / source_w, page_h / source_h)
contain = min(page_w / source_w, page_h / source_h)
scale = cover * max(contain / cover, min(zoom, 4.0))
image_w, image_h = source_w * scale, source_h * scale
room_x = (page_w - image_w) / 2
room_y = (page_h - image_h) / 2
image_x = room_x * (1 - max(-1, min(pan_x, 1)))
image_y = room_y * (1 - max(-1, min(pan_y, 1)))
return image_x, image_y, image_w, image_h, contain / cover
class CoverCanvas(QWidget):
changed = Signal()
editFinished = Signal()
activeSideChanged = Signal(str)
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setMinimumSize(620, 360)
self.setMouseTracking(True)
self.cover = CoverLayout()
self.active_side = "front"
self._previews: dict[str, QImage] = {}
self._source_sizes: dict[str, tuple[int, int]] = {}
self._spread_rect = QRectF()
self._panel_rects: dict[str, QRectF] = {}
self._drag_pos: QPointF | None = None
self._drag_side: str | None = None
self._drag_start_pan = (0.0, 0.0)
self._drag_text_role: str | None = None
self._drag_start_text_position = (0.5, 0.17)
self._front_text_hit_rects: dict[str, QRectF] = {}
self._front_text_bounds: dict[str, QRectF] = {}
self._selected_front_text: str | None = None
self._spine_text_hit_rects: dict[str, QRectF] = {}
self._selected_spine_text: str | None = None
self._drag_spine_role: str | None = None
self._drag_start_spine_y = 0.0
self._drag_spine_half_extent_in = 0.0
self._drag_other_spine_y: float | None = None
self._drag_other_spine_half_extent_in = 0.0
def set_cover(self, cover: CoverLayout) -> None:
self.cover = cover
self._render_previews()
def set_active_side(self, side: str) -> None:
if side not in ("front", "back"):
return
self.active_side = side
self.update()
def resizeEvent(self, event) -> None: # noqa: N802 - Qt event name
super().resizeEvent(event)
self._render_previews()
def _margins(self) -> tuple[float, float, float, float]:
return (
float(self.cover.margin_left_in or 0.0),
float(self.cover.margin_right_in or 0.0),
float(self.cover.margin_top_in or 0.0),
float(self.cover.margin_bottom_in or 0.0),
)
def _layout_rects(self) -> None:
margin_left, _, margin_top, _ = self._margins()
spread_width, spread_height = self.cover.spread_size_in
aspect = spread_width / spread_height if spread_height else 1.0
usable_w = max(100.0, self.width() - 50.0)
usable_h = max(100.0, self.height() - 72.0)
if usable_w / usable_h > aspect:
height = usable_h
width = height * aspect
else:
width = usable_w
height = width / aspect
self._spread_rect = QRectF(
(self.width() - width) / 2,
(self.height() - height) / 2 + 10,
width,
height,
)
scale = width / spread_width if spread_width else 1.0
face_top = self._spread_rect.top() + margin_top * scale
face_h = self.cover.face_height_in * scale
back_left = self._spread_rect.left() + margin_left * scale
face_w = self.cover.face_width_in * scale
spine_w = self.cover.spine_width_in * scale
back_rect = QRectF(back_left, face_top, face_w, face_h)
spine_rect = QRectF(back_rect.right(), face_top, spine_w, face_h)
front_rect = QRectF(spine_rect.right(), face_top, face_w, face_h)
self._panel_rects = {"back": back_rect, "front": front_rect, "spine": spine_rect}
def _render_previews(self) -> None:
self._layout_rects()
self._previews.clear()
self._source_sizes.clear()
for side, page in (("back", self.cover.back_page), ("front", self.cover.front_page)):
if not page or not page.image_path or not Path(page.image_path).is_file():
continue
rect = self._panel_rects[side]
preview_size = (
max(1, round(rect.width() * 1.6)),
max(1, round(rect.height() * 1.6)),
)
try:
preview = render_photo(
page.image_path,
preview_size,
page.zoom,
page.pan_x,
page.pan_y,
preview=True,
)
self._previews[side] = _pil_to_qimage(preview)
preview.close()
self._source_sizes[side] = _photo_size(page.image_path)
except (OSError, ValueError):
continue
self.update()
@staticmethod
def _fitted_font_size(
text: str,
family: str,
base_size: float,
max_length_pt: float,
) -> float:
if not text.strip():
return base_size
metrics_font = QFont(family)
metrics_font.setPointSizeF(base_size)
measured = QFontMetricsF(metrics_font).horizontalAdvance(text) * 72 / 96
return min(base_size, base_size * max_length_pt / measured) if measured else base_size
def _paint_spine_text(
self,
painter: QPainter,
role: str,
text: str,
y_from_bottom: float,
max_length_pt: float,
family: str,
font_size_pt: float,
color: str,
) -> None:
if not text.strip():
return
spine_rect = self._panel_rects["spine"]
face_h = self.cover.face_height_in
font_pt = self._fitted_font_size(text, family, font_size_pt, max_length_pt)
scale = spine_rect.width() / self.cover.spine_width_in if self.cover.spine_width_in else 1.0
px_size = max(1, round(font_pt / 72 * scale))
painter.save()
painter.translate(
spine_rect.center().x(),
spine_rect.top() + (face_h - y_from_bottom) * scale,
)
painter.rotate(90)
font = QFont(family)
font.setPixelSize(px_size)
painter.setFont(font)
painter.setPen(QColor(color))
text_rect = QRectF(
-max_length_pt / 72 * scale / 2,
-spine_rect.width() / 2,
max_length_pt / 72 * scale,
spine_rect.width(),
)
text_length_px = min(
text_rect.width(), QFontMetricsF(font).horizontalAdvance(text)
)
painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, text)
painter.restore()
center_y = spine_rect.top() + (face_h - y_from_bottom) * scale
hit_rect = QRectF(
spine_rect.left() - 3,
center_y - text_length_px / 2 - 5,
spine_rect.width() + 6,
text_length_px + 10,
)
self._spine_text_hit_rects[role] = hit_rect
if role == self._selected_spine_text:
painter.setPen(QPen(QColor("#ffd35a"), 1.5, Qt.PenStyle.DashLine))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(hit_rect)
def _front_text_path(
self,
role: str,
) -> tuple[QPainterPath, QRectF, QColor, float] | None:
text = getattr(self.cover, f"front_{role}")
if not text.strip():
return None
front_rect = self._panel_rects["front"]
base_size = getattr(self.cover, f"front_{role}_font_size_pt")
scale = front_rect.width() / self.cover.face_width_in if self.cover.face_width_in else 1.0
font_px = max(1, round(base_size / 72 * scale))
font = QFont(self.cover.front_font_family)
font.setPixelSize(font_px)
path = QPainterPath()
path.addText(QPointF(0, 0), font, text)
bounds = path.boundingRect()
max_width = front_rect.width() * 0.9
if bounds.width() > max_width:
font_px = max(1, round(font_px * max_width / bounds.width()))
font.setPixelSize(font_px)
path = QPainterPath()
path.addText(QPointF(0, 0), font, text)
bounds = path.boundingRect()
x_fraction = getattr(self.cover, f"front_{role}_x")
y_fraction = getattr(self.cover, f"front_{role}_y")
target = QPointF(
front_rect.left() + x_fraction * front_rect.width(),
front_rect.top() + y_fraction * front_rect.height(),
)
path.translate(target.x() - bounds.center().x(), target.y() - bounds.center().y())
bounds = path.boundingRect()
outline_radius = FRONT_COVER_OUTLINE_RADIUS_PX * scale / EXPORT_PPI
hit_rect = bounds.adjusted(
-outline_radius - 5,
-outline_radius - 5,
outline_radius + 5,
outline_radius + 5,
)
color = QColor(self.cover.title_color if role == "title" else self.cover.subtitle_color)
return path, hit_rect, color, outline_radius
def _paint_front_cover_text(self, painter: QPainter) -> None:
self._front_text_hit_rects.clear()
self._front_text_bounds.clear()
for role in ("title", "subtitle"):
text_layout = self._front_text_path(role)
if text_layout is None:
continue
path, hit_rect, color, outline_radius = text_layout
painter.setPen(
QPen(
QColor("#000000"),
outline_radius * 2,
Qt.PenStyle.SolidLine,
Qt.PenCapStyle.RoundCap,
Qt.PenJoinStyle.RoundJoin,
)
)
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawPath(path)
painter.setPen(QPen(Qt.PenStyle.NoPen))
painter.setBrush(color)
painter.drawPath(path)
bounds = path.boundingRect()
self._front_text_hit_rects[role] = hit_rect
self._front_text_bounds[role] = bounds
if role == self._selected_front_text:
painter.setPen(QPen(QColor("#ffd35a"), 1.5, Qt.PenStyle.DashLine))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(hit_rect)
def paintEvent(self, event) -> None: # noqa: N802 - Qt event name
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.fillRect(self.rect(), QColor("#252932"))
self._layout_rects()
painter.fillRect(self._spread_rect, QColor("white"))
spine_rect = self._panel_rects["spine"]
spine_bleed_rect = QRectF(
spine_rect.left(),
self._spread_rect.top(),
spine_rect.width(),
self._spread_rect.height(),
)
painter.fillRect(spine_bleed_rect, QColor("#263b50"))
painter.setPen(QPen(QColor("#f2f3f5"), 1))
painter.drawRect(self._spread_rect)
for side, label in (("back", "Back cover"), ("front", "Front cover")):
rect = self._panel_rects[side]
preview = self._previews.get(side)
if preview is not None:
top_band = QRectF(rect.left(), self._spread_rect.top(), rect.width(), rect.top() - self._spread_rect.top())
bottom_band = QRectF(rect.left(), rect.bottom(), rect.width(), self._spread_rect.bottom() - rect.bottom())
if top_band.height() > 0:
painter.drawImage(
top_band,
preview,
QRectF(0, 0, preview.width(), 1),
)
if bottom_band.height() > 0:
painter.drawImage(
bottom_band,
preview,
QRectF(0, preview.height() - 1, preview.width(), 1),
)
if side == "back" and rect.left() > self._spread_rect.left():
left_band = QRectF(
self._spread_rect.left(), rect.top(), rect.left() - self._spread_rect.left(), rect.height()
)
painter.drawImage(left_band, preview, QRectF(0, 0, 1, preview.height()))
if top_band.height() > 0:
painter.fillRect(
QRectF(self._spread_rect.left(), top_band.top(), left_band.width(), top_band.height()),
preview.pixelColor(0, 0),
)
if bottom_band.height() > 0:
painter.fillRect(
QRectF(self._spread_rect.left(), bottom_band.top(), left_band.width(), bottom_band.height()),
preview.pixelColor(0, preview.height() - 1),
)
if side == "front" and rect.right() < self._spread_rect.right():
right_band = QRectF(
rect.right(), rect.top(), self._spread_rect.right() - rect.right(), rect.height()
)
painter.drawImage(
right_band,
preview,
QRectF(preview.width() - 1, 0, 1, preview.height()),
)
if top_band.height() > 0:
painter.fillRect(
QRectF(rect.right(), top_band.top(), right_band.width(), top_band.height()),
preview.pixelColor(preview.width() - 1, 0),
)
if bottom_band.height() > 0:
painter.fillRect(
QRectF(rect.right(), bottom_band.top(), right_band.width(), bottom_band.height()),
preview.pixelColor(preview.width() - 1, preview.height() - 1),
)
painter.fillRect(rect, QColor("white"))
if preview is not None:
painter.drawImage(rect, preview)
else:
painter.setPen(QColor("#6c727c"))
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, label)
painter.setPen(QPen(QColor("#65b9db"), 1, Qt.PenStyle.DashLine))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(rect)
painter.setPen(QPen(QColor("#d19cff"), 1, Qt.PenStyle.DashLine))
painter.drawRect(spine_rect)
self._spine_text_hit_rects.clear()
title_y, subtitle_y, title_extent, subtitle_extent = self.cover.spine_text_layout
subtitle_zone_height = min(SPINE_SUBTITLE_ZONE_HEIGHT_IN, self.cover.face_height_in)
subtitle_scale = spine_rect.height() / self.cover.face_height_in if self.cover.face_height_in else 1.0
subtitle_rect = QRectF(
spine_rect.left(),
spine_rect.bottom() - subtitle_zone_height * subtitle_scale,
spine_rect.width(),
subtitle_zone_height * subtitle_scale,
)
painter.setPen(QPen(QColor("#ffc857"), 1, Qt.PenStyle.DashLine))
painter.drawRect(subtitle_rect)
self._paint_spine_text(
painter,
"title",
self.cover.title,
title_y,
title_extent,
self.cover.title_font_family,
self.cover.title_font_size_pt,
self.cover.title_color,
)
self._paint_spine_text(
painter,
"subtitle",
self.cover.subtitle,
subtitle_y,
subtitle_extent,
self.cover.subtitle_font_family,
self.cover.subtitle_font_size_pt,
self.cover.subtitle_color,
)
self._paint_front_cover_text(painter)
active = self._panel_rects.get(self.active_side)
page = self.cover.front_page if self.active_side == "front" else self.cover.back_page
if active and page:
painter.setPen(QPen(QColor("#ffd35a"), 2))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(active)
painter.setPen(QColor("#d1d5dc"))
painter.drawText(
QRectF(0, 6, self.width(), 24),
Qt.AlignmentFlag.AlignCenter,
"BACK COVER | SPINE | FRONT COVER",
)
painter.end()
def _spine_text_half_extent_in(self, role: str) -> float:
text = getattr(self.cover, role)
if not text.strip():
return 0.0
layout = self.cover.spine_text_layout
max_length_pt = max(1.0, layout[2 if role == "title" else 3])
max_length_in = max_length_pt / 72
family = getattr(self.cover, f"{role}_font_family")
size_pt = getattr(self.cover, f"{role}_font_size_pt")
fitted_size = self._fitted_font_size(text, family, size_pt, max_length_pt)
spine_rect = self._panel_rects["spine"]
scale = spine_rect.width() / self.cover.spine_width_in if self.cover.spine_width_in else 1.0
font = QFont(family)
font.setPixelSize(max(1, round(fitted_size / 72 * scale)))
text_length_in = QFontMetricsF(font).horizontalAdvance(text) / max(scale, 1e-6)
return min(max_length_in, text_length_in) / 2
def mousePressEvent(self, event) -> None: # noqa: N802 - Qt event name
if event.button() == Qt.MouseButton.LeftButton:
spine_rect = self._panel_rects.get("spine")
if spine_rect is not None and spine_rect.contains(event.position()):
point = event.position()
hit_roles = [
role
for role, hit_rect in self._spine_text_hit_rects.items()
if hit_rect.contains(point)
]
if hit_roles:
role = min(
hit_roles,
key=lambda selected_role: abs(
self._spine_text_hit_rects[selected_role].center().y() - point.y()
),
)
layout = self.cover.spine_text_layout
role_index = 0 if role == "title" else 1
other_role = "subtitle" if role == "title" else "title"
self._selected_spine_text = role
self._selected_front_text = None
self._drag_spine_role = role
self._drag_text_role = None
self._drag_side = None
self._drag_pos = point
self._drag_start_spine_y = layout[role_index]
self._drag_spine_half_extent_in = self._spine_text_half_extent_in(role)
self._drag_other_spine_y = (
layout[1 - role_index]
if getattr(self.cover, other_role).strip()
else None
)
self._drag_other_spine_half_extent_in = self._spine_text_half_extent_in(
other_role
)
self.setCursor(Qt.CursorShape.ClosedHandCursor)
self.update()
event.accept()
return
self._selected_spine_text = None
self._selected_front_text = None
self.setCursor(Qt.CursorShape.ArrowCursor)
self.update()
event.accept()
return
for side in ("back", "front"):
rect = self._panel_rects.get(side)
if rect is None or not rect.contains(event.position()):
continue
self.active_side = side
self.activeSideChanged.emit(side)
if side == "front":
hit_roles = [
role
for role, hit_rect in self._front_text_hit_rects.items()
if hit_rect.contains(event.position())
]
if hit_roles:
point = event.position()
role = min(
hit_roles,
key=lambda selected_role: (
self._front_text_bounds[selected_role].center().x() - point.x()
) ** 2
+ (
self._front_text_bounds[selected_role].center().y() - point.y()
) ** 2,
)
self._selected_front_text = role
self._selected_spine_text = None
self._drag_text_role = role
self._drag_pos = point
self._drag_start_text_position = (
getattr(self.cover, f"front_{role}_x"),
getattr(self.cover, f"front_{role}_y"),
)
self.setCursor(Qt.CursorShape.ClosedHandCursor)
self.update()
event.accept()
return
self._selected_front_text = None
self._selected_spine_text = None
self._drag_spine_role = None
page = self.cover.front_page if side == "front" else self.cover.back_page
if page and page.image_path and side in self._previews:
self._drag_pos = event.position()
self._drag_side = side
self._drag_text_role = None
self._drag_start_pan = (page.pan_x, page.pan_y)
self.setCursor(Qt.CursorShape.ClosedHandCursor)
self.update()
event.accept()
return
super().mousePressEvent(event)
def mouseMoveEvent(self, event) -> None: # noqa: N802 - Qt event name
if self._drag_pos is not None and self._drag_spine_role:
role = self._drag_spine_role
spine_rect = self._panel_rects["spine"]
face_height = self.cover.face_height_in
if spine_rect.height() > 0 and face_height > 0:
point = event.position()
scale = spine_rect.height() / face_height
desired_y = self._drag_start_spine_y - (
point.y() - self._drag_pos.y()
) / scale
zone_height = (
face_height
if role == "title"
else min(SPINE_SUBTITLE_ZONE_HEIGHT_IN, face_height)
)
own_half = min(self._drag_spine_half_extent_in, zone_height / 2)
low = own_half
high = zone_height - own_half
gap = 0.08
intervals = [(low, high)]
if self._drag_other_spine_y is not None:
safe_low = (
self._drag_other_spine_y
- self._drag_other_spine_half_extent_in
- own_half
- gap
)
safe_high = (
self._drag_other_spine_y
+ self._drag_other_spine_half_extent_in
+ own_half
+ gap
)
intervals = [
(low, min(high, safe_low)),
(max(low, safe_high), high),
]
valid_intervals = [
interval for interval in intervals if interval[0] <= interval[1]
]
if valid_intervals:
new_y = min(
(
max(start, min(end, desired_y))
for start, end in valid_intervals
),
key=lambda candidate: abs(candidate - desired_y),
)
setattr(self.cover, f"spine_{role}_y_in", new_y)
self.update()
self.changed.emit()
event.accept()
return
if self._drag_pos is not None and self._drag_text_role:
role = self._drag_text_role
front_rect = self._panel_rects["front"]
hit_rect = self._front_text_hit_rects.get(role)
point = event.position()
if hit_rect is not None and front_rect.width() > 0 and front_rect.height() > 0:
min_x = min(0.5, hit_rect.width() / (2 * front_rect.width()))
min_y = min(0.5, hit_rect.height() / (2 * front_rect.height()))
x = self._drag_start_text_position[0] + (
point.x() - self._drag_pos.x()
) / front_rect.width()
y = self._drag_start_text_position[1] + (
point.y() - self._drag_pos.y()
) / front_rect.height()
setattr(self.cover, f"front_{role}_x", max(min_x, min(1 - min_x, x)))
setattr(self.cover, f"front_{role}_y", max(min_y, min(1 - min_y, y)))
self.update()
self.changed.emit()
event.accept()
return
if self._drag_pos is not None and self._drag_side and self._source_sizes.get(self._drag_side):
side = self._drag_side
page = self.cover.front_page if side == "front" else self.cover.back_page
rect = self._panel_rects[side]
preview = self._previews.get(side)
if page is None or preview is None:
return
point = event.position()
dx = point.x() - self._drag_pos.x()
dy = point.y() - self._drag_pos.y()
relative_x = rect.width() / max(1, preview.width())
relative_y = rect.height() / max(1, preview.height())
image_x, image_y, image_w, image_h, _ = _cover_geometry_for_ui(
self._source_sizes[side],
(preview.width(), preview.height()),
page.zoom,
self._drag_start_pan[0],
self._drag_start_pan[1],
)
denom_x = (image_w - preview.width()) / 2
denom_y = (image_h - preview.height()) / 2
page.pan_x = max(
-1.0,
min(
self._drag_start_pan[0]
+ (dx / relative_x / denom_x if abs(denom_x) > 1e-6 else 0),
1.0,
),
)
page.pan_y = max(
-1.0,
min(
self._drag_start_pan[1]
+ (dy / relative_y / denom_y if abs(denom_y) > 1e-6 else 0),
1.0,
),
)
self._render_previews()
self.changed.emit()
event.accept()
return
spine_rect = self._panel_rects.get("spine")
spine_text_hovered = bool(
spine_rect
and spine_rect.contains(event.position())
and any(
hit_rect.contains(event.position())
for hit_rect in self._spine_text_hit_rects.values()
)
)
self.setCursor(
Qt.CursorShape.OpenHandCursor
if spine_text_hovered
else Qt.CursorShape.ArrowCursor
)
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event) -> None: # noqa: N802 - Qt event name
if event.button() == Qt.MouseButton.LeftButton and self._drag_pos is not None:
photo_was_dragged = self._drag_side is not None
self._drag_pos = None
self._drag_side = None
self._drag_text_role = None
self._drag_spine_role = None
self.setCursor(Qt.CursorShape.ArrowCursor)
if photo_was_dragged:
self.editFinished.emit()
event.accept()
return
super().mouseReleaseEvent(event)
class PrintSetupDialog(QDialog):
def __init__(self, profile: PrintProfile, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setWindowTitle("Blurb print setup")
self.setMinimumWidth(480)
self._profile = copy.deepcopy(profile)
description = QLabel(
"Trim size is the finished page area. Enter the final exported PDF page dimensions "
"and trim offsets from the Blurb specification for your selected format, paper, "
"and cover. PDF pages include the bleed area. Page limits also vary by format and paper."
)
description.setWordWrap(True)
self.trim_width = self._spin(profile.trim_width_in, 0.1, 30, " in")
self.trim_height = self._spin(profile.trim_height_in, 0.1, 30, " in")
self.pdf_width = self._line(profile.pdf_width_in)
self.pdf_height = self._line(profile.pdf_height_in)
self.trim_binding = self._spin(profile.trim_binding_in, 0, 30, " in")
self.trim_outside = self._spin(profile.trim_outside_in, 0, 30, " in")
self.trim_top = self._spin(profile.trim_top_in, 0, 30, " in")
self.trim_bottom = self._spin(profile.trim_bottom_in, 0, 30, " in")
self.min_pages = QSpinBox()
self.min_pages.setRange(1, 2000)
self.min_pages.setValue(profile.min_pages)
self.max_pages = QSpinBox()
self.max_pages.setRange(1, 2000)
self.max_pages.setValue(profile.max_pages)
form = QFormLayout()
form.addRow("Trim width", self.trim_width)
form.addRow("Trim height", self.trim_height)
form.addRow("Final PDF page width", self.pdf_width)
form.addRow("Final PDF page height", self.pdf_height)
form.addRow("Trim inset from binding edge", self.trim_binding)
form.addRow("Trim inset from outside edge", self.trim_outside)
form.addRow("Trim inset from top edge", self.trim_top)
form.addRow("Trim inset from bottom edge", self.trim_bottom)
form.addRow("Minimum pages", self.min_pages)
form.addRow("Maximum pages for selected format", self.max_pages)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel
)
buttons.accepted.connect(self._try_accept)
buttons.rejected.connect(self.reject)
layout = QVBoxLayout(self)
layout.addWidget(description)
layout.addLayout(form)
layout.addWidget(buttons)
@staticmethod
def _spin(value: float, minimum: float, maximum: float, suffix: str) -> QDoubleSpinBox:
spin = QDoubleSpinBox()
spin.setRange(minimum, maximum)
spin.setDecimals(3)
spin.setSingleStep(0.125)
spin.setSuffix(suffix)
spin.setValue(value)
return spin
@staticmethod
def _line(value: float | None) -> QLineEdit:
line = QLineEdit("" if value is None else f"{value:g}")
line.setPlaceholderText("Required, including bleed")
return line
def _try_accept(self) -> None:
try:
pdf_width = float(self.pdf_width.text())
pdf_height = float(self.pdf_height.text())
except ValueError:
QMessageBox.warning(self, "Print setup", "Enter numeric final PDF page width and height.")
return
self._profile = PrintProfile(
trim_width_in=self.trim_width.value(),
trim_height_in=self.trim_height.value(),
pdf_width_in=pdf_width,
pdf_height_in=pdf_height,
trim_binding_in=self.trim_binding.value(),
trim_outside_in=self.trim_outside.value(),
trim_top_in=self.trim_top.value(),
trim_bottom_in=self.trim_bottom.value(),
min_pages=self.min_pages.value(),
max_pages=self.max_pages.value(),
)
errors = self._profile.validate()
if errors:
QMessageBox.warning(self, "Print setup", "\n".join(errors))
return
self.accept()
@property
def profile(self) -> PrintProfile:
return self._profile
class ExportWorker(QThread):
progressChanged = Signal(int, int)
succeeded = Signal(str)
failed = Signal(str)
def __init__(self, project: BookProject, output_path: str) -> None:
super().__init__()
self.project = project
self.output_path = output_path
def run(self) -> None:
interior_path: Path | None = None
include_cover = self.project.cover.has_content
total_interior_pages = len(self.project.pages)
total_work = total_interior_pages + (1 if include_cover else 0)
try:
interior_path = export_pdf(
self.project,
self.output_path,
progress=lambda done, total: self.progressChanged.emit(done, total_work),
)
output_paths = [interior_path]
if include_cover:
cover_path = interior_path.with_name(f"{interior_path.stem}-cover.pdf")
output_paths.append(
export_cover_pdf(
self.project,
cover_path,
progress=lambda done, total: self.progressChanged.emit(
total_interior_pages + done, total_work
),
)
)
self.succeeded.emit("\n".join(str(path) for path in output_paths))
except Exception as exc: # The error is shown in the app so export failures are actionable.
if interior_path is not None and include_cover:
self.failed.emit(
f"Interior PDF saved to:\n{interior_path}\n\nCover PDF export failed:\n{exc}"
)
return
self.failed.emit(str(exc))
class CoverExportWorker(QThread):
progressChanged = Signal(int, int)
succeeded = Signal(str)
failed = Signal(str)
def __init__(self, project: BookProject, output_path: str) -> None:
super().__init__()
self.project = project
self.output_path = output_path
def run(self) -> None:
try:
output = export_cover_pdf(
self.project,
self.output_path,
progress=lambda done, total: self.progressChanged.emit(done, total),
)
self.succeeded.emit(str(output))
except Exception as exc:
self.failed.emit(str(exc))
class FolderImportWorker(QThread):
stageChanged = Signal(str, int)
progressChanged = Signal(int, int)
completed = Signal(object, object, object, object)
failed = Signal(str)
def __init__(
self,
folder: str,
known_paths: list[str] | None = None,
profile: PrintProfile | None = None,
) -> None:
super().__init__()
self.folder = folder
self.known_path_keys = {_photo_path_key(path) for path in (known_paths or [])}
self.profile = copy.deepcopy(profile) if profile is not None else None
self.is_rescan = known_paths is not None
def run(self) -> None:
try:
if self.is_rescan:
photos, issues = scan_photo_folder(self.folder)
new_photos = [
(path, capture_date)
for path, capture_date in photos
if _photo_path_key(path) not in self.known_path_keys
]
project = BookProject(
photo_paths=[str(path) for path, _ in new_photos],
pages=[PhotoPage(image_path=str(path)) for path, _ in new_photos],
profile=self.profile or PrintProfile(),
source_directory=str(Path(self.folder).resolve()),
capture_dates={str(path): capture_date for path, capture_date in new_photos},
)
else:
project, issues = BookProject.from_folder(self.folder)
total = len(project.pages)
self.stageChanged.emit(
"Preparing new photo previews…" if self.is_rescan else "Preparing photo previews…",
total,
)
photo_dates: dict[str, str] = {}
thumbnails: dict[int, QImage] = {}
page_width, page_height = _page_dimensions(project.profile)
thumbnail_width = 150
thumbnail_height = max(1, round(thumbnail_width * page_height / page_width))
for index, page in enumerate(project.pages, start=1):
capture_date = project.capture_dates.get(page.image_path or "")
if capture_date is not None and capture_date != datetime.max and page.image_path:
photo_dates[page.image_path] = capture_date.strftime("%m/%d/%Y")
try:
preview = render_photo(
page.image_path or "",
(thumbnail_width, thumbnail_height),
page.zoom,
page.pan_x,
page.pan_y,
preview=True,
)
try:
thumbnails[id(page)] = _pil_to_qimage(preview)
finally:
preview.close()
except Exception:
placeholder = Image.new(
"RGB", (thumbnail_width, thumbnail_height), "#e8c7c7"
)
thumbnails[id(page)] = _pil_to_qimage(placeholder)
placeholder.close()
self.progressChanged.emit(index, total)
if project.pages and project.pages[0].image_path:
first_page = project.pages[0]
try:
first_preview = render_photo(
first_page.image_path,
(thumbnail_width, thumbnail_height),
first_page.zoom,
first_page.pan_x,
first_page.pan_y,
preview=True,
)
first_preview.close()
except (OSError, ValueError):
pass
self.completed.emit(project, issues, photo_dates, thumbnails)
except Exception as exc:
self.failed.emit(str(exc))
class ProjectLoadWorker(QThread):
stageChanged = Signal(str, int)
progressChanged = Signal(int, int)
completed = Signal(object, object, object)
failed = Signal(str)
def __init__(self, path: str) -> None:
super().__init__()
self.path = path
def run(self) -> None:
try:
project = BookProject.load(self.path)
pages = list(project.pages)
pages.extend(
page
for page in (project.cover.back_page, project.cover.front_page)
if page is not None
)
total = len(pages)
self.stageChanged.emit("Preparing project photo previews…", total)
photo_dates: dict[str, str] = {}
thumbnails: dict[int, QImage] = {}
page_width, page_height = _page_dimensions(project.profile)
thumbnail_width = 150
thumbnail_height = max(1, round(thumbnail_width * page_height / page_width))
for index, page in enumerate(pages, start=1):
path = page.image_path
if path and Path(path).is_file():
capture_date = photo_sort_date(path)
if capture_date != datetime.max:
photo_dates[path] = capture_date.strftime("%m/%d/%Y")
try:
preview = render_photo(
path,
(thumbnail_width, thumbnail_height),
page.zoom,
page.pan_x,
page.pan_y,
preview=True,
)
try:
thumbnails[id(page)] = _pil_to_qimage(preview)
finally:
preview.close()
except Exception:
placeholder = Image.new(
"RGB", (thumbnail_width, thumbnail_height), "#e8c7c7"
)
thumbnails[id(page)] = _pil_to_qimage(placeholder)
placeholder.close()
self.progressChanged.emit(index, total)
self.completed.emit(project, photo_dates, thumbnails)
except Exception as exc:
self.failed.emit(str(exc))
class PhotoBookWindow(QMainWindow):
def __init__(self) -> None:
super().__init__()
self.setWindowTitle("Blurb Photo Book Editor")
self.resize(1480, 920)
self.project = BookProject()
_resolve_cover_font_families(self.project.cover)
self.project_path: str | None = None
self.settings = QSettings("Blurb", "Blurb Photo Book Editor")
self._photo_date_cache: dict[str, str] = {}
self._photo_thumbnail_cache: dict[int, QPixmap] = {}
self.view_mode = "all"
self.workspace_mode = "book"
self._cover_controls_loading = False
self.dirty = False
self.current_page_index = -1
self.worker: ExportWorker | None = None
self.cover_export_worker: CoverExportWorker | None = None
self.folder_import_worker: FolderImportWorker | None = None
self.import_progress_dialog: QProgressDialog | None = None
self.project_load_worker: ProjectLoadWorker | None = None
self.project_load_dialog: QProgressDialog | None = None
self._pending_project_load_error: str | None = None
self._pending_import_issues: list[str] = []
self._pending_import_error: str | None = None
self._pending_empty_import = False
self._pending_import_mode = "replace"
self._pending_rescan_folder: str | None = None
self._pending_rescan_count: int | None = None
self.pages_list = PageListWidget()
self.pages_list.currentRowChanged.connect(self._selection_changed)
self.pages_list.orderChanged.connect(self._order_changed)
self.pages_list.customContextMenuRequested.connect(self._show_page_context_menu)
self.cover_list = QListWidget()
self.cover_list.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
self.cover_list.setDragDropMode(QAbstractItemView.DragDropMode.NoDragDrop)
self.cover_list.setIconSize(QSize(110, 64))
self.cover_list.setMinimumHeight(48)
self.cover_list.setMaximumHeight(152)
self.canvas = PageCanvas()
self.canvas.changed.connect(self._mark_dirty)
self.canvas.editFinished.connect(self._selected_thumbnail_changed)
self.cover_canvas = CoverCanvas()
self.cover_canvas.changed.connect(self._cover_image_changed)
self.cover_canvas.editFinished.connect(self._cover_image_edit_finished)
self.cover_canvas.activeSideChanged.connect(self._cover_active_side_changed)
self.zoom_slider = QSlider(Qt.Orientation.Horizontal)
self.zoom_slider.setRange(0, 100)
self.zoom_slider.valueChanged.connect(self._zoom_changed)
self.zoom_label = QLabel("100% of cover")
self.zoom_out = QToolButton()
self.zoom_out.setText("−")
self.zoom_out.setToolTip("Zoom out")
self.zoom_out.clicked.connect(lambda: self._step_zoom(1 / 1.12))
self.zoom_in = QToolButton()
self.zoom_in.setText("+")
self.zoom_in.setToolTip("Zoom in")
self.zoom_in.clicked.connect(lambda: self._step_zoom(1.12))
self.insert_blank_button = QPushButton("Insert blank")
self.insert_blank_button.clicked.connect(self._insert_blank_page)
self.insert_photo_button = QPushButton("Insert photo")
self.insert_photo_button.clicked.connect(self._insert_photo_page)
self.remove_button = QPushButton("Remove page")
self.remove_button.clicked.connect(self._remove_page)
self.relink_button = QPushButton("Relink photo…")
self.relink_button.clicked.connect(self._relink_photo)
self.remove_back_cover_button = QPushButton("Remove back cover")
self.remove_back_cover_button.clicked.connect(lambda: self._remove_cover("back"))
self.remove_front_cover_button = QPushButton("Remove front cover")
self.remove_front_cover_button.clicked.connect(lambda: self._remove_cover("front"))
self.cover_title_edit = QLineEdit()
self.cover_title_edit.setPlaceholderText("Title")
self.cover_title_edit.textEdited.connect(self._cover_title_changed)
self.cover_subtitle_edit = QLineEdit()
self.cover_subtitle_edit.setPlaceholderText("Subtitle")
self.cover_subtitle_edit.textEdited.connect(self._cover_subtitle_changed)
self.front_title_edit = QLineEdit()
self.front_title_edit.setPlaceholderText("Front cover title")
self.front_title_edit.textEdited.connect(self._front_title_changed)
self.front_subtitle_edit = QLineEdit()
self.front_subtitle_edit.setPlaceholderText("Front cover subtitle")
self.front_subtitle_edit.textEdited.connect(self._front_subtitle_changed)
self.front_font_combo = QFontComboBox()
self.front_font_combo.setToolTip("Font family shared by the front title and subtitle")
self.front_font_combo.currentFontChanged.connect(
lambda font: self._front_font_changed(font.family())
)
self.front_title_size_spin = self._front_font_size_spin(48.0)
self.front_title_size_spin.setToolTip("Front cover title size")
self.front_title_size_spin.valueChanged.connect(
lambda value: self._front_font_size_changed("title", value)
)
self.front_subtitle_size_spin = self._front_font_size_spin(24.0)
self.front_subtitle_size_spin.setToolTip("Front cover subtitle size")
self.front_subtitle_size_spin.valueChanged.connect(
lambda value: self._front_font_size_changed("subtitle", value)
)
self.cover_font_combos: dict[str, QFontComboBox] = {}
self.cover_font_sizes: dict[str, QDoubleSpinBox] = {}
self.cover_color_buttons: dict[str, QPushButton] = {}
for role, default_size in (("title", 20.0), ("subtitle", 14.0)):
font_combo = QFontComboBox()
font_combo.setMinimumWidth(125)
font_combo.setMaximumWidth(185)
font_combo.setToolTip(f"{role.title()} font family")
font_combo.currentFontChanged.connect(
lambda font, selected_role=role: self._cover_font_changed(
selected_role, font.family()
)
)
font_size = self._cover_font_size_spin(default_size)
font_size.setToolTip(f"{role.title()} font size")
font_size.valueChanged.connect(
lambda value, selected_role=role: self._cover_font_size_changed(
selected_role, value
)
)
color_button = QPushButton()
color_button.setMaximumWidth(86)
color_button.clicked.connect(
lambda _checked=False, selected_role=role: self._choose_cover_color(
selected_role
)
)
self.cover_font_combos[role] = font_combo
self.cover_font_sizes[role] = font_size
self.cover_color_buttons[role] = color_button
self.cover_face_width = self._cover_dimension_spin(10.074)
self.cover_face_height = self._cover_dimension_spin(9.0)
self.cover_spine_width = self._cover_dimension_spin(0.57)
for spin in (self.cover_face_width, self.cover_face_height, self.cover_spine_width):
spin.valueChanged.connect(self._cover_dimensions_changed)
self.cover_margin_edits: dict[str, QLineEdit] = {}
for side in ("left", "right", "top", "bottom"):
edit = QLineEdit()
edit.setPlaceholderText("inches")
edit.editingFinished.connect(
lambda selected_side=side: self._cover_margin_changed(selected_side)
)
self.cover_margin_edits[side] = edit
left = QWidget()
left_layout = QVBoxLayout(left)
left_layout.setContentsMargins(10, 10, 10, 10)
self.pages_heading = QLabel("PAGES · drag to reorder")
left_layout.addWidget(self.pages_heading)
left_layout.addWidget(self.pages_list, 1)
self.cover_heading = QLabel("COVER PHOTOS")
self.cover_heading.setStyleSheet("font-weight: bold; color: #d5d8de; padding-top: 4px;")
left_layout.addWidget(self.cover_heading)
left_layout.addWidget(self.cover_list)
page_controls = QHBoxLayout()
page_controls.addWidget(self.insert_blank_button)
page_controls.addWidget(self.insert_photo_button)
left_layout.addLayout(page_controls)
page_controls2 = QHBoxLayout()
page_controls2.addWidget(self.remove_button)
page_controls2.addWidget(self.relink_button)
left_layout.addLayout(page_controls2)
self.editor_stack = QStackedWidget()
book_workspace = QWidget()
book_layout = QVBoxLayout(book_workspace)
book_layout.setContentsMargins(0, 0, 0, 0)
book_layout.addWidget(self.canvas, 1)
self.editor_stack.addWidget(book_workspace)
cover_workspace = QWidget()
cover_layout = QVBoxLayout(cover_workspace)
cover_layout.setContentsMargins(0, 0, 0, 0)
cover_layout.addWidget(self.cover_canvas, 1)
cover_form = QFormLayout()
def cover_text_row(role: str, text_edit: QLineEdit) -> QWidget:
row = QWidget()
row_layout = QHBoxLayout(row)
row_layout.setContentsMargins(0, 0, 0, 0)
row_layout.setSpacing(6)
row_layout.addWidget(text_edit, 1)
row_layout.addWidget(self.cover_font_combos[role])
row_layout.addWidget(self.cover_font_sizes[role])
row_layout.addWidget(self.cover_color_buttons[role])
return row
cover_form.addRow("Spine title", cover_text_row("title", self.cover_title_edit))
cover_form.addRow(
"Spine subtitle", cover_text_row("subtitle", self.cover_subtitle_edit)
)
cover_form.addRow("Front cover font", self.front_font_combo)
def front_text_row(text_edit: QLineEdit, size_spin: QDoubleSpinBox) -> QWidget:
row = QWidget()
row_layout = QHBoxLayout(row)
row_layout.setContentsMargins(0, 0, 0, 0)
row_layout.setSpacing(6)
row_layout.addWidget(text_edit, 1)
row_layout.addWidget(size_spin)
return row
cover_form.addRow(
"Front title", front_text_row(self.front_title_edit, self.front_title_size_spin)
)
cover_form.addRow(
"Front subtitle",
front_text_row(self.front_subtitle_edit, self.front_subtitle_size_spin),
)
def cover_field_pair(label_text: str, field: QWidget, max_width: int) -> QWidget:
field.setMaximumWidth(max_width)
pair = QWidget()
pair_layout = QHBoxLayout(pair)
pair_layout.setContentsMargins(0, 0, 0, 0)
pair_layout.setSpacing(5)
pair_layout.addWidget(QLabel(label_text))
pair_layout.addWidget(field)
return pair
dimensions_row = QWidget()
dimensions_layout = QHBoxLayout(dimensions_row)
dimensions_layout.setContentsMargins(0, 0, 0, 0)
dimensions = (
("Face width", self.cover_face_width),
("Face height", self.cover_face_height),
("Spine", self.cover_spine_width),
)
for index, (label, spin) in enumerate(dimensions):
if index:
dimensions_layout.addStretch(1)
dimensions_layout.addWidget(cover_field_pair(label, spin, 165))
cover_form.addRow("Dimensions", dimensions_row)
margins_row = QWidget()
margins_layout = QHBoxLayout(margins_row)
margins_layout.setContentsMargins(0, 0, 0, 0)
for index, side in enumerate(("left", "right", "top", "bottom")):
if index:
margins_layout.addStretch(1)
margins_layout.addWidget(
cover_field_pair(side.title(), self.cover_margin_edits[side], 135)
)
cover_form.addRow("Wrap/bleed margins", margins_row)
cover_layout.addLayout(cover_form)
cover_margin_hint = QLabel(
"Default margins are 0.000 in on each edge. Verify the exact wrap/bleed values for your Blurb format, paper, and page count."
)
cover_margin_hint.setWordWrap(True)
cover_margin_hint.setStyleSheet("color: #aab1bd; padding-left: 4px;")
cover_layout.addWidget(cover_margin_hint)
cover_button_row = QHBoxLayout()
cover_button_row.addWidget(self.remove_back_cover_button)
cover_button_row.addWidget(self.remove_front_cover_button)
cover_button_row.addStretch(1)
cover_layout.addLayout(cover_button_row)
self.editor_stack.addWidget(cover_workspace)
right = QWidget()
right_layout = QVBoxLayout(right)
right_layout.setContentsMargins(12, 10, 12, 10)
right_layout.addWidget(self.editor_stack, 1)
zoom_controls = QHBoxLayout()
zoom_controls.addStretch(1)
zoom_controls.addWidget(QLabel("Zoom"))
zoom_controls.addWidget(self.zoom_out)
zoom_controls.addWidget(self.zoom_slider, 1)
zoom_controls.addWidget(self.zoom_in)
zoom_controls.addWidget(self.zoom_label)
zoom_controls.addStretch(1)
right_layout.addLayout(zoom_controls)
self.editor_hint = QLabel()
self.editor_hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.editor_hint.setStyleSheet("color: #aab1bd; padding: 3px;")
right_layout.addWidget(self.editor_hint)
splitter = QSplitter(Qt.Orientation.Horizontal)
splitter.addWidget(left)
splitter.addWidget(right)
splitter.setStretchFactor(0, 0)
splitter.setStretchFactor(1, 1)
splitter.setSizes([360, 1120])
self.setCentralWidget(splitter)
self._create_actions()
self._create_status()
self._update_actions()
self._set_dirty(False)
self._refresh_canvas()
self._refresh_cover_canvas()
self._set_workspace("book")
def _create_actions(self) -> None:
file_menu = self.menuBar().addMenu("&File")
self.open_folder_action = QAction("Import photo folder…", self)
self.open_folder_action.triggered.connect(self._import_folder)
file_menu.addAction(self.open_folder_action)
self.rescan_folder_action = QAction("Rescan Folder for New Photos…", self)
self.rescan_folder_action.triggered.connect(self._rescan_folder)
file_menu.addAction(self.rescan_folder_action)
self.open_project_action = QAction("Open project…", self)
self.open_project_action.triggered.connect(self._open_project)
file_menu.addAction(self.open_project_action)
self.open_recent_menu = file_menu.addMenu("Open Recent")
self.open_recent_menu.aboutToShow.connect(self._populate_recent_menu)
self._populate_recent_menu()
self.save_action = QAction("Save project", self)
self.save_action.setShortcut("Ctrl+S")
self.save_action.triggered.connect(self._save_project)
file_menu.addAction(self.save_action)
self.save_as_action = QAction("Save project as…", self)
self.save_as_action.setShortcut("Ctrl+Shift+S")
self.save_as_action.triggered.connect(self._save_project_as)
file_menu.addAction(self.save_as_action)
file_menu.addSeparator()
self.print_setup_action = QAction("Blurb print setup…", self)
self.print_setup_action.triggered.connect(self._edit_print_setup)
file_menu.addAction(self.print_setup_action)
self.export_action = QAction("Export PDF…", self)
self.export_action.setShortcut("Ctrl+E")
self.export_action.triggered.connect(self._export_pdf)
file_menu.addAction(self.export_action)
self.export_cover_action = QAction("Export Cover PDF…", self)
self.export_cover_action.triggered.connect(self._export_cover_pdf)
file_menu.addAction(self.export_cover_action)
file_menu.addSeparator()
exit_action = QAction("Exit", self)
exit_action.triggered.connect(self.close)
file_menu.addAction(exit_action)
view_menu = self.menuBar().addMenu("&View")
self.view_action_group = QActionGroup(self)
self.view_action_group.setExclusive(True)
self.view_all_action = QAction("View All", self)
self.view_all_action.setCheckable(True)
self.view_all_action.setChecked(True)
self.view_all_action.triggered.connect(lambda checked=False: self._set_view_mode("all"))
self.view_action_group.addAction(self.view_all_action)
view_menu.addAction(self.view_all_action)
self.view_tagged_action = QAction("View Tagged", self)
self.view_tagged_action.setCheckable(True)
self.view_tagged_action.triggered.connect(lambda checked=False: self._set_view_mode("tagged"))
self.view_action_group.addAction(self.view_tagged_action)
view_menu.addAction(self.view_tagged_action)
view_menu.addSeparator()
self.workspace_action_group = QActionGroup(self)
self.workspace_action_group.setExclusive(True)
self.book_editor_action = QAction("Book Editor", self)
self.book_editor_action.setCheckable(True)
self.book_editor_action.setChecked(True)
self.book_editor_action.triggered.connect(lambda checked=False: self._set_workspace("book"))
self.workspace_action_group.addAction(self.book_editor_action)
view_menu.addAction(self.book_editor_action)
self.cover_editor_action = QAction("Cover Editor", self)
self.cover_editor_action.setCheckable(True)
self.cover_editor_action.triggered.connect(lambda checked=False: self._set_workspace("cover"))
self.workspace_action_group.addAction(self.cover_editor_action)
view_menu.addAction(self.cover_editor_action)
self.reset_spine_text_action = QAction("Reset spine text positions", self)
self.reset_spine_text_action.setVisible(False)
self.reset_spine_text_action.triggered.connect(self._reset_spine_text_positions)
view_menu.addAction(self.reset_spine_text_action)
toolbar = QToolBar("Main")
toolbar.setMovable(False)
self.addToolBar(toolbar)
for action in (
self.open_folder_action,
self.open_project_action,
self.save_action,
self.print_setup_action,
self.cover_editor_action,
self.export_action,
):
toolbar.addAction(action)
self.progress = QProgressBar()
self.progress.setMaximumWidth(230)
self.progress.setFixedHeight(18)
self.progress.setVisible(False)
toolbar.addWidget(self.progress)
def _create_status(self) -> None:
status = QStatusBar()
self.setStatusBar(status)
self.status_label = QLabel("Import a folder of photos to begin.")
status.addWidget(self.status_label, 1)
@staticmethod
def _cover_dimension_spin(value: float) -> QDoubleSpinBox:
spin = QDoubleSpinBox()
spin.setRange(0.001, 30.0)
spin.setDecimals(3)
spin.setSingleStep(0.125)
spin.setSuffix(" in")
spin.setValue(value)
return spin
@staticmethod
def _cover_font_size_spin(value: float) -> QDoubleSpinBox:
spin = QDoubleSpinBox()
spin.setRange(4.0, 72.0)
spin.setDecimals(1)
spin.setSingleStep(1.0)
spin.setSuffix(" pt")
spin.setMaximumWidth(88)
spin.setValue(value)
return spin
@staticmethod
def _front_font_size_spin(value: float) -> QDoubleSpinBox:
spin = QDoubleSpinBox()
spin.setRange(4.0, 144.0)
spin.setDecimals(1)
spin.setSingleStep(1.0)
spin.setSuffix(" pt")
spin.setMaximumWidth(88)
spin.setValue(value)
return spin
def _set_workspace(self, mode: str) -> None:
self.workspace_mode = "cover" if mode == "cover" else "book"
self.editor_stack.setCurrentIndex(1 if self.workspace_mode == "cover" else 0)
self.book_editor_action.setChecked(self.workspace_mode == "book")
self.cover_editor_action.setChecked(self.workspace_mode == "cover")
self.reset_spine_text_action.setVisible(self.workspace_mode == "cover")
self.editor_hint.setText(
"Click a panel to select it; drag photos to crop them, front-cover text to position it, or spine text up/down. View → Reset spine text positions restores the original spine layout. Front text uses matching spine colors with a 7 px black outline; purple/gold dashed boxes mark the spine text areas."
if self.workspace_mode == "cover"
else "Drag inside the page to reposition the photo. Photos start cropped to fill the page."
)
if self.workspace_mode == "cover":
self._refresh_cover_canvas()
else:
self._refresh_canvas()
self._sync_zoom_control()
self._update_actions()
def _reset_spine_text_positions(self) -> None:
cover = self.project.cover
if cover.spine_title_y_in is None and cover.spine_subtitle_y_in is None:
return
cover.spine_title_y_in = None
cover.spine_subtitle_y_in = None
self.cover_canvas.update()
self.reset_spine_text_action.setEnabled(False)
self._mark_dirty()
def _refresh_cover_canvas(self) -> None:
if hasattr(self, "cover_canvas"):
self.cover_canvas.set_cover(self.project.cover)
self.cover_canvas.set_active_side(self.cover_canvas.active_side)
if hasattr(self, "cover_title_edit"):
self._sync_cover_controls()
if hasattr(self, "remove_back_cover_button"):
self.remove_back_cover_button.setEnabled(self.project.cover.back_page is not None)
self.remove_front_cover_button.setEnabled(self.project.cover.front_page is not None)
if hasattr(self, "cover_list"):
self._refresh_cover_list()
def _refresh_cover_list(self) -> None:
self.cover_list.clear()
assigned = (
("Front Cover", self.project.cover.front_page),
("Back Cover", self.project.cover.back_page),
)
for label, page in assigned:
if page is None:
continue
filename = Path(page.image_path).name if page.image_path else "Missing image"
item = QListWidgetItem(f"[{label.upper()}] · {filename}")
item.setData(Qt.ItemDataRole.UserRole, page)
if page.image_path:
item.setIcon(self._page_icon(page))
item.setToolTip(f"Used as the {label.lower()}")
item.setSizeHint(QSize(300, 64))
self.cover_list.addItem(item)
if not self.cover_list.count():
item = QListWidgetItem("No cover photos assigned")
item.setFlags(Qt.ItemFlag.NoItemFlags)
self.cover_list.addItem(item)
def _sync_cover_controls(self) -> None:
cover = self.project.cover
self._cover_controls_loading = True
for edit, value in (
(self.cover_title_edit, cover.title),
(self.cover_subtitle_edit, cover.subtitle),
(self.front_title_edit, cover.front_title),
(self.front_subtitle_edit, cover.front_subtitle),
):
edit.blockSignals(True)
edit.setText(value)
edit.blockSignals(False)
self.front_font_combo.blockSignals(True)
self.front_font_combo.setCurrentFont(QFont(cover.front_font_family))
self.front_font_combo.blockSignals(False)
for spin, value in (
(self.front_title_size_spin, cover.front_title_font_size_pt),
(self.front_subtitle_size_spin, cover.front_subtitle_font_size_pt),
):
spin.blockSignals(True)
spin.setValue(value)
spin.blockSignals(False)
for role in ("title", "subtitle"):
family_combo = self.cover_font_combos[role]
family_combo.blockSignals(True)
family_combo.setCurrentFont(QFont(getattr(cover, f"{role}_font_family")))
family_combo.blockSignals(False)
size_spin = self.cover_font_sizes[role]
size_spin.blockSignals(True)
size_spin.setValue(getattr(cover, f"{role}_font_size_pt"))
size_spin.blockSignals(False)
self._set_cover_color_button(role, getattr(cover, f"{role}_color"))
for spin, value in (
(self.cover_face_width, cover.face_width_in),
(self.cover_face_height, cover.face_height_in),
(self.cover_spine_width, cover.spine_width_in),
):
spin.blockSignals(True)
spin.setValue(value)
spin.blockSignals(False)
for side, edit in self.cover_margin_edits.items():
value = getattr(cover, f"margin_{side}_in")
edit.blockSignals(True)
edit.setText("" if value is None else f"{value:g}")
edit.blockSignals(False)
self._cover_controls_loading = False
def _cover_title_changed(self, text: str) -> None:
if self._cover_controls_loading:
return
self.project.cover.title = text
self.cover_canvas.update()
self._mark_dirty()
def _cover_subtitle_changed(self, text: str) -> None:
if self._cover_controls_loading:
return
self.project.cover.subtitle = text
self.cover_canvas.update()
self._mark_dirty()
def _front_title_changed(self, text: str) -> None:
if self._cover_controls_loading:
return
self.project.cover.front_title = text
self.cover_canvas.update()
self._mark_dirty()
def _front_subtitle_changed(self, text: str) -> None:
if self._cover_controls_loading:
return
self.project.cover.front_subtitle = text
self.cover_canvas.update()
self._mark_dirty()
def _front_font_changed(self, family: str) -> None:
if self._cover_controls_loading:
return
self.project.cover.front_font_family = family
self.cover_canvas.update()
self._mark_dirty()
def _front_font_size_changed(self, role: str, size_pt: float) -> None:
if self._cover_controls_loading:
return
setattr(self.project.cover, f"front_{role}_font_size_pt", size_pt)
self.cover_canvas.update()
self._mark_dirty()
def _cover_font_changed(self, role: str, family: str) -> None:
if self._cover_controls_loading:
return
setattr(self.project.cover, f"{role}_font_family", family)
self.cover_canvas.update()
self._mark_dirty()
def _cover_font_size_changed(self, role: str, size_pt: float) -> None:
if self._cover_controls_loading:
return
setattr(self.project.cover, f"{role}_font_size_pt", size_pt)
self.cover_canvas.update()
self._mark_dirty()
def _set_cover_color_button(self, role: str, color: str) -> None:
button = self.cover_color_buttons[role]
selected = QColor(color)
if not selected.isValid():
selected = QColor("#fff03f")
ink = "#111111" if selected.lightness() > 145 else "#ffffff"
button.setText(selected.name().upper())
button.setToolTip(f"{role.title()} text color — click to choose")
button.setStyleSheet(
f"QPushButton {{ background-color: {selected.name()}; color: {ink}; }}"
)
def _choose_cover_color(self, role: str) -> None:
current = getattr(self.project.cover, f"{role}_color")
selected = QColorDialog.getColor(QColor(current), self, f"{role.title()} text color")
if not selected.isValid():
return
color = selected.name()
setattr(self.project.cover, f"{role}_color", color)
self._set_cover_color_button(role, color)
self.cover_canvas.update()
self._mark_dirty()
def _cover_dimensions_changed(self, _value: float) -> None:
if self._cover_controls_loading:
return
self.project.cover.face_width_in = self.cover_face_width.value()
self.project.cover.face_height_in = self.cover_face_height.value()
self.project.cover.spine_width_in = self.cover_spine_width.value()
self.cover_canvas.set_cover(self.project.cover)
self._sync_zoom_control()
self._mark_dirty()
def _cover_margin_changed(self, side: str) -> None:
if self._cover_controls_loading:
return
edit = self.cover_margin_edits[side]
value_text = edit.text().strip()
value: float | None = None
if value_text:
try:
value = float(value_text)
except ValueError:
QMessageBox.warning(self, "Cover setup", f"Enter a numeric {side} margin in inches.")
self._sync_cover_controls()
return
if not 0 <= value <= 30:
QMessageBox.warning(self, "Cover setup", f"The {side} margin must be between 0 and 30 inches.")
self._sync_cover_controls()
return
setattr(self.project.cover, f"margin_{side}_in", value)
self.cover_canvas.set_cover(self.project.cover)
self._mark_dirty()
def _active_cover_page(self) -> PhotoPage | None:
return (
self.project.cover.front_page
if self.cover_canvas.active_side == "front"
else self.project.cover.back_page
)
def _cover_active_side_changed(self, side: str) -> None:
self.cover_canvas.set_active_side(side)
self._sync_zoom_control()
self._update_actions()
def _cover_image_changed(self) -> None:
self.reset_spine_text_action.setEnabled(
self.project.cover.spine_title_y_in is not None
or self.project.cover.spine_subtitle_y_in is not None
)
self._mark_dirty()
def _cover_image_edit_finished(self) -> None:
page = self._active_cover_page()
if page is not None:
self._photo_thumbnail_cache.pop(id(page), None)
self._refresh_cover_list()
self._sync_zoom_control()
self._mark_dirty()
def _set_dirty(self, dirty: bool) -> None:
self.dirty = dirty
path_label = Path(self.project_path).name if self.project_path else "Untitled project"
self.setWindowTitle(f"{'* ' if dirty else ''}{path_label} — Blurb Photo Book Editor")
self.save_action.setEnabled(dirty or self.project_path is None)
def _mark_dirty(self) -> None:
self._set_dirty(True)
self._update_status()
def _update_status(self) -> None:
count = len(self.project.pages)
photo_count = sum(1 for page in self.project.pages if page.image_path)
odd = " · Blurb export needs an even page count" if count % 2 else ""
if count < self.project.profile.min_pages or count > self.project.profile.max_pages:
odd += (
f" · outside page range {self.project.profile.min_pages}–"
f"{self.project.profile.max_pages}"
)
missing = sum(
1 for page in self.project.pages if page.image_path and not Path(page.image_path).is_file()
)
missing_text = f" · {missing} missing photo(s)" if missing else ""
setup = "Print setup needed" if not self.project.profile.ready else "Print setup ready"
cover_text = ""
if self.project.cover.has_content:
cover_text = (
" · cover setup needed"
if self.project.cover.validate()
else " · cover setup ready"
)
self.status_label.setText(
f"{count} pages · {photo_count} photo pages · {setup}{odd}{missing_text}{cover_text}"
)
def _update_actions(self) -> None:
self._sync_view_menu()
self.reset_spine_text_action.setEnabled(
self.project.cover.spine_title_y_in is not None
or self.project.cover.spine_subtitle_y_in is not None
)
selected = self._page_at_row(self.pages_list.currentRow())
has_selection = selected is not None
self.remove_button.setEnabled(has_selection)
can_insert = self.view_mode == "all"
self.insert_blank_button.setEnabled(can_insert)
self.insert_photo_button.setEnabled(can_insert)
insert_tip = "Switch to View All to insert pages." if not can_insert else ""
self.insert_blank_button.setToolTip(insert_tip)
self.insert_photo_button.setToolTip(insert_tip)
missing = bool(selected and selected.image_path and not Path(selected.image_path).is_file())
self.relink_button.setEnabled(missing)
zoom_page = self._active_cover_page() if self.workspace_mode == "cover" else selected
self.zoom_slider.setEnabled(
bool(zoom_page and zoom_page.image_path and Path(zoom_page.image_path).is_file())
)
self.zoom_in.setEnabled(self.zoom_slider.isEnabled())
self.zoom_out.setEnabled(self.zoom_slider.isEnabled())
self._update_status()
def _page_at_row(self, row: int) -> PhotoPage | None:
if not 0 <= row < self.pages_list.count():
return None
page = self.pages_list.item(row).data(Qt.ItemDataRole.UserRole)
return page if isinstance(page, PhotoPage) else None
def _project_index_for_page(self, page: PhotoPage | None) -> int:
if page is None:
return -1
return next(
(index for index, candidate in enumerate(self.project.pages) if candidate is page),
-1,
)
def _has_tagged_photos(self) -> bool:
return any(page.image_path and page.tagged for page in self.project.pages)
def _visible_page_entries(self) -> list[tuple[int, PhotoPage]]:
return [
(index, page)
for index, page in enumerate(self.project.pages)
if self.view_mode == "all" or (page.image_path and page.tagged)
]
def _sync_view_menu(self) -> None:
has_tagged_photos = self._has_tagged_photos()
if self.view_mode == "tagged" and not has_tagged_photos:
self.view_mode = "all"
if hasattr(self, "view_tagged_action"):
self.view_tagged_action.setEnabled(has_tagged_photos)
self.view_all_action.setChecked(self.view_mode == "all")
self.view_tagged_action.setChecked(self.view_mode == "tagged")
if self.view_mode == "all":
self.pages_list.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
self.pages_heading.setText("PAGES · drag to reorder")
else:
self.pages_list.setDragDropMode(QAbstractItemView.DragDropMode.NoDragDrop)
self.pages_heading.setText("PAGES · tagged only")
def _set_view_mode(self, mode: str) -> None:
if mode == "tagged" and not self._has_tagged_photos():
return
selected_index = self._project_index_for_page(self._page_at_row(self.pages_list.currentRow()))
self.view_mode = mode
self._sync_view_menu()
self._refresh_page_list(selected_index if selected_index >= 0 else None)
def _page_icon(self, page: PhotoPage) -> QPixmap:
if not page.image_path or not Path(page.image_path).is_file():
pixmap = QPixmap(150, 126)
pixmap.fill(QColor("#e8e9ed"))
else:
pixmap = self._photo_thumbnail_cache.get(id(page))
if pixmap is None:
aspect = (self.project.profile.pdf_width_in or self.project.profile.trim_width_in) / (
self.project.profile.pdf_height_in or self.project.profile.trim_height_in
)
thumb_w = 150
thumb_h = max(1, round(thumb_w / aspect))
image: Image.Image | None = None
try:
image = render_photo(
page.image_path,
(thumb_w, thumb_h),
page.zoom,
page.pan_x,
page.pan_y,
preview=True,
)
pixmap = QPixmap.fromImage(_pil_to_qimage(image))
except (OSError, ValueError):
pixmap = QPixmap(150, 126)
pixmap.fill(QColor("#e8c7c7"))
finally:
if image is not None:
image.close()
self._photo_thumbnail_cache[id(page)] = QPixmap(pixmap)
else:
pixmap = QPixmap(pixmap)
if page.tagged:
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
badge_size = 24
badge = QRectF(pixmap.width() - badge_size - 5, 5, badge_size, badge_size)
painter.setPen(QPen(QColor("white"), 2))
painter.setBrush(QColor("#e53935"))
painter.drawEllipse(badge)
painter.setPen(QColor("white"))
font = QFont()
font.setBold(True)
font.setPointSize(10)
painter.setFont(font)
painter.drawText(badge, Qt.AlignmentFlag.AlignCenter, "T")
painter.end()
return pixmap
def _photo_date_label(self, path: str) -> str:
if path not in self._photo_date_cache:
date_text = ""
if Path(path).is_file():
capture_date = photo_sort_date(path)
if capture_date != datetime.max:
date_text = capture_date.strftime("%m/%d/%Y")
self._photo_date_cache[path] = date_text
else:
return ""
return self._photo_date_cache[path]
def _page_label(self, page: PhotoPage, index: int) -> str:
if not page.image_path:
return f"Page {index + 1:03d} · Blank page"
elif not Path(page.image_path).is_file():
detail = f"Page {index + 1:03d} · Missing · {Path(page.image_path).name}"
else:
detail = f"Page {index + 1:03d} · {Path(page.image_path).name}"
date_text = self._photo_date_label(page.image_path)
return f"{detail}\n{date_text}" if date_text else detail
return detail
def _refresh_page_list(self, selected_index: int | None = None) -> None:
if selected_index is None:
selected_index = self._project_index_for_page(
self._page_at_row(self.pages_list.currentRow())
)
self._sync_view_menu()
entries = self._visible_page_entries()
selected_row = -1
self.pages_list.blockSignals(True)
self.pages_list.clear()
for index, page in entries:
item = QListWidgetItem(self._page_label(page, index))
item.setData(Qt.ItemDataRole.UserRole, page)
item.setIcon(self._page_icon(page))
item.setToolTip("Tagged page" if page.tagged else "")
item.setSizeHint(QSize(310, 142))
self.pages_list.addItem(item)
if entries:
selected_row = next(
(row for row, (index, _) in enumerate(entries) if index == selected_index),
next(
(row for row, (index, _) in enumerate(entries) if index >= selected_index),
len(entries) - 1,
),
)
self.pages_list.blockSignals(False)
self.pages_list.setCurrentRow(selected_row)
self._selection_changed(self.pages_list.currentRow())
self._refresh_cover_list()
self._sync_cover_controls()
self.cover_canvas.set_cover(self.project.cover)
self.remove_back_cover_button.setEnabled(self.project.cover.back_page is not None)
self.remove_front_cover_button.setEnabled(self.project.cover.front_page is not None)
self._update_actions()
def _refresh_one_page(self, index: int, *, refresh_thumbnail: bool = False) -> None:
if 0 <= index < self.pages_list.count():
page = self._page_at_row(index)
if page is None:
return
if refresh_thumbnail:
self._photo_thumbnail_cache.pop(id(page), None)
project_index = self._project_index_for_page(page)
item = self.pages_list.item(index)
item.setText(self._page_label(page, project_index))
item.setIcon(self._page_icon(page))
item.setToolTip("Tagged page" if page.tagged else "")
def _refresh_canvas(self) -> None:
page = self._page_at_row(self.pages_list.currentRow())
project_index = self._project_index_for_page(page)
self.canvas.set_page(
page,
self.project.profile,
project_index if page else 0,
len(self.project.pages),
)
def _selection_changed(self, row: int) -> None:
page = self._page_at_row(row)
project_index = self._project_index_for_page(page)
self.current_page_index = project_index
self.canvas.set_page(
page,
self.project.profile,
project_index if page else 0,
len(self.project.pages),
)
self._sync_zoom_control()
self._update_actions()
def _sync_zoom_control(self) -> None:
page = self._active_cover_page() if self.workspace_mode == "cover" else self._page_at_row(
self.pages_list.currentRow()
)
if not page or not page.image_path or not Path(page.image_path).is_file():
self.zoom_slider.blockSignals(True)
self.zoom_slider.setValue(0)
self.zoom_slider.blockSignals(False)
self.zoom_label.setText("—")
return
if self.workspace_mode == "cover":
width, height = self.project.cover.face_width_in, self.project.cover.face_height_in
else:
width, height = _page_dimensions(self.project.profile)
min_zoom, max_zoom = page_zoom_bounds(page.image_path, width / height)
value = round((page.zoom - min_zoom) / max(0.001, max_zoom - min_zoom) * 100)
self.zoom_slider.blockSignals(True)
self.zoom_slider.setValue(max(0, min(100, value)))
self.zoom_slider.blockSignals(False)
self.zoom_label.setText(f"{page.zoom * 100:.0f}% of cover")
def _zoom_changed(self, value: int) -> None:
page = self._active_cover_page() if self.workspace_mode == "cover" else self._page_at_row(
self.pages_list.currentRow()
)
if page is None:
return
if not page.image_path or not Path(page.image_path).is_file():
return
if self.workspace_mode == "cover":
width, height = self.project.cover.face_width_in, self.project.cover.face_height_in
else:
width, height = _page_dimensions(self.project.profile)
min_zoom, max_zoom = page_zoom_bounds(page.image_path, width / height)
next_zoom = min_zoom + (max_zoom - min_zoom) * value / 100
page.pan_x, page.pan_y = pan_for_centered_zoom(
_photo_size(page.image_path),
(width, height),
page.zoom,
next_zoom,
page.pan_x,
page.pan_y,
)
page.zoom = next_zoom
self.zoom_label.setText(f"{page.zoom * 100:.0f}% of cover")
if self.workspace_mode == "cover":
self.cover_canvas.set_cover(self.project.cover)
else:
self.canvas.set_page(
page,
self.project.profile,
self._project_index_for_page(page),
len(self.project.pages),
)
self._mark_dirty()
if self.workspace_mode == "cover":
self._cover_image_edit_finished()
else:
self._refresh_one_page(self.pages_list.currentRow(), refresh_thumbnail=True)
def _step_zoom(self, factor: float) -> None:
page = self._active_cover_page() if self.workspace_mode == "cover" else self._page_at_row(
self.pages_list.currentRow()
)
if page is None:
return
if not page.image_path or not Path(page.image_path).is_file():
return
if self.workspace_mode == "cover":
width, height = self.project.cover.face_width_in, self.project.cover.face_height_in
else:
width, height = _page_dimensions(self.project.profile)
min_zoom, max_zoom = page_zoom_bounds(page.image_path, width / height)
next_zoom = max(min_zoom, min(max_zoom, page.zoom * factor))
page.pan_x, page.pan_y = pan_for_centered_zoom(
_photo_size(page.image_path),
(width, height),
page.zoom,
next_zoom,
page.pan_x,
page.pan_y,
)
page.zoom = next_zoom
self._sync_zoom_control()
if self.workspace_mode == "cover":
self.cover_canvas.set_cover(self.project.cover)
else:
self.canvas.set_page(
page,
self.project.profile,
self._project_index_for_page(page),
len(self.project.pages),
)
self._mark_dirty()
if self.workspace_mode == "cover":
self._cover_image_edit_finished()
else:
self._refresh_one_page(self.pages_list.currentRow(), refresh_thumbnail=True)
def _selected_thumbnail_changed(self) -> None:
row = self.pages_list.currentRow()
self._refresh_one_page(row, refresh_thumbnail=True)
self._sync_zoom_control()
self._mark_dirty()
def _order_changed(self) -> None:
if self.view_mode != "all":
self._refresh_page_list()
return
self.project.pages = [
self.pages_list.item(index).data(Qt.ItemDataRole.UserRole)
for index in range(self.pages_list.count())
]
for index in range(self.pages_list.count()):
self.pages_list.item(index).setText(self._page_label(self.project.pages[index], index))
self._refresh_canvas()
self._mark_dirty()
def _show_page_context_menu(self, position) -> None:
item = self.pages_list.itemAt(position)
if item is None:
return
row = self.pages_list.row(item)
self.pages_list.setCurrentRow(row)
page = self._page_at_row(row)
if page is None:
return
menu = QMenu(self)
action = menu.addAction("Untag page" if page.tagged else "Tag page")
action.triggered.connect(lambda checked=False, selected_row=row: self._toggle_page_tag(selected_row))
if page.image_path:
menu.addSeparator()
front_action = menu.addAction("Make Front Cover")
front_action.setEnabled(Path(page.image_path).is_file())
front_action.triggered.connect(
lambda checked=False, selected_page=page: self._assign_cover("front", selected_page)
)
back_action = menu.addAction("Make Back Cover")
back_action.setEnabled(Path(page.image_path).is_file())
back_action.triggered.connect(
lambda checked=False, selected_page=page: self._assign_cover("back", selected_page)
)
menu.exec(self.pages_list.mapToGlobal(position))
@staticmethod
def _chronological_key(page: PhotoPage) -> tuple[datetime, str, str]:
path = page.image_path or ""
return photo_sort_date(path), Path(path).name.casefold(), path.casefold()
def _insert_page_chronologically(self, page: PhotoPage) -> int:
if not page.image_path:
self.project.pages.append(page)
return len(self.project.pages) - 1
target_key = self._chronological_key(page)
for index, existing in enumerate(self.project.pages):
if existing.image_path and self._chronological_key(existing) > target_key:
self.project.pages.insert(index, page)
return index
self.project.pages.append(page)
return len(self.project.pages) - 1
def _assign_cover(self, side: str, page: PhotoPage) -> None:
if side not in ("front", "back") or not page.image_path:
return
index = self._project_index_for_page(page)
if index < 0:
return
del self.project.pages[index]
slot_name = f"{side}_page"
previous_page = getattr(self.project.cover, slot_name)
setattr(self.project.cover, slot_name, page)
self.cover_canvas.set_active_side(side)
if previous_page is not None:
self._insert_page_chronologically(previous_page)
self._photo_thumbnail_cache.pop(id(page), None)
self._set_dirty(True)
self._refresh_page_list(min(index, len(self.project.pages) - 1) if self.project.pages else None)
def _remove_cover(self, side: str) -> None:
if side not in ("front", "back"):
return
slot_name = f"{side}_page"
page = getattr(self.project.cover, slot_name)
if page is None:
return
setattr(self.project.cover, slot_name, None)
index = self._insert_page_chronologically(page)
self._set_dirty(True)
self._refresh_page_list(index)
def _toggle_page_tag(self, row: int) -> None:
page = self._page_at_row(row)
if page is None:
return
project_index = self._project_index_for_page(page)
page.tagged = not page.tagged
if self.view_mode == "tagged":
self._refresh_page_list(project_index)
else:
self._refresh_one_page(row)
self._sync_view_menu()
self._mark_dirty()
def _insert_at(self) -> int:
selected = self._project_index_for_page(self._page_at_row(self.pages_list.currentRow()))
return selected + 1 if selected >= 0 else len(self.project.pages)
def _insert_blank_page(self) -> None:
index = self._insert_at()
self.project.pages.insert(index, PhotoPage())
self._set_dirty(True)
self._refresh_page_list(index)
def _insert_photo_page(self) -> None:
folder = self.project.source_directory or str(Path.home())
filters = "Photo files (" + " ".join(f"*{ext}" for ext in sorted(SUPPORTED_EXTENSIONS)) + ")"
path, _ = QFileDialog.getOpenFileName(self, "Choose a photo for the new page", folder, filters)
if not path:
return
path = str(Path(path).resolve())
if path not in self.project.photo_paths:
self.project.photo_paths.append(path)
index = self._insert_at()
self.project.pages.insert(index, PhotoPage(image_path=path))
self._set_dirty(True)
self._refresh_page_list(index)
def _remove_page(self) -> None:
row = self.pages_list.currentRow()
page = self._page_at_row(row)
if page is None:
return
project_index = self._project_index_for_page(page)
detail = Path(page.image_path).name if page.image_path else "blank page"
box = QMessageBox(self)
box.setWindowTitle("Remove page")
box.setText(f"Remove this page ({detail}) from the project? The source photo will not be deleted.")
remove = box.addButton("Remove page", QMessageBox.ButtonRole.DestructiveRole)
box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole)
box.exec()
if box.clickedButton() is not remove:
return
del self.project.pages[project_index]
self._photo_thumbnail_cache.pop(id(page), None)
self._set_dirty(True)
self._refresh_page_list(project_index)
def _relink_photo(self) -> None:
row = self.pages_list.currentRow()
page = self._page_at_row(row)
if page is None:
return
project_index = self._project_index_for_page(page)
old = page.image_path
start = self.project.source_directory or str(Path.home())
if old:
parent = Path(old).parent
if parent.is_dir():
start = str(parent)
filters = "Photo files (" + " ".join(f"*{ext}" for ext in sorted(SUPPORTED_EXTENSIONS)) + ")"
path, _ = QFileDialog.getOpenFileName(self, "Relink missing photo", start, filters)
if not path:
return
path = str(Path(path).resolve())
page.image_path = path
self._photo_thumbnail_cache.pop(id(page), None)
if path not in self.project.photo_paths:
self.project.photo_paths.append(path)
self._set_dirty(True)
self._refresh_page_list(project_index)
def _maybe_save(self) -> bool:
if not self.dirty:
return True
box = QMessageBox(self)
box.setWindowTitle("Unsaved project")
box.setText("Save changes to the current photo book project?")
box.setStandardButtons(
QMessageBox.StandardButton.Save
| QMessageBox.StandardButton.Discard
| QMessageBox.StandardButton.Cancel
)
box.setDefaultButton(QMessageBox.StandardButton.Save)
choice = box.exec()
if choice == QMessageBox.StandardButton.Cancel:
return False
if choice == QMessageBox.StandardButton.Save:
return self._save_project()
return True
def _import_folder(self) -> None:
if self.folder_import_worker and self.folder_import_worker.isRunning():
return
if not self._maybe_save():
return
folder = QFileDialog.getExistingDirectory(self, "Choose photo folder", self.project.source_directory or str(Path.home()))
if not folder:
return
self._start_folder_scan(folder, "replace")
def _rescan_folder(self) -> None:
if self.folder_import_worker and self.folder_import_worker.isRunning():
return
starting_folder = self.project.source_directory or str(Path.home())
folder = QFileDialog.getExistingDirectory(
self,
"Rescan photo folder for new images",
starting_folder,
)
if not folder:
return
known_paths = [page.image_path for page in self.project.pages if page.image_path]
for page in (self.project.cover.back_page, self.project.cover.front_page):
if page and page.image_path:
known_paths.append(page.image_path)
self._start_folder_scan(folder, "rescan", known_paths)
def _start_folder_scan(
self,
folder: str,
mode: str,
known_paths: list[str] | None = None,
) -> None:
self._pending_import_issues = []
self._pending_import_error = None
self._pending_empty_import = False
self._pending_import_mode = mode
self._pending_rescan_folder = folder if mode == "rescan" else None
self._pending_rescan_count = None
is_rescan = mode == "rescan"
dialog = QProgressDialog(
"Scanning for new photos…" if is_rescan else "Scanning photo folder…",
"",
0,
0,
self,
)
dialog.setWindowTitle("Rescanning photos" if is_rescan else "Importing photos")
dialog.setWindowModality(Qt.WindowModality.WindowModal)
dialog.setCancelButton(None)
dialog.setMinimumDuration(0)
dialog.setAutoClose(False)
dialog.setAutoReset(False)
dialog.setWindowFlags(dialog.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
self.import_progress_dialog = dialog
self.folder_import_worker = FolderImportWorker(
folder,
known_paths=known_paths,
profile=self.project.profile if is_rescan else None,
)
self.folder_import_worker.stageChanged.connect(self._folder_import_stage_changed)
self.folder_import_worker.progressChanged.connect(self._folder_import_progress_changed)
self.folder_import_worker.completed.connect(self._folder_import_completed)
self.folder_import_worker.failed.connect(self._folder_import_failed)
self.folder_import_worker.finished.connect(self._folder_import_worker_finished)
dialog.show()
self.folder_import_worker.start()
def _folder_import_stage_changed(self, message: str, total: int) -> None:
if not self.import_progress_dialog:
return
self.import_progress_dialog.setLabelText(message)
self.import_progress_dialog.setRange(0, total)
self.import_progress_dialog.setValue(0)
def _folder_import_progress_changed(self, completed: int, total: int) -> None:
if self.import_progress_dialog:
phase = (
"Preparing new photo previews"
if self._pending_import_mode == "rescan"
else "Preparing photo previews"
)
self.import_progress_dialog.setLabelText(
f"{phase}… {completed} of {total}"
)
self.import_progress_dialog.setValue(completed)
def _folder_import_completed(
self,
project: BookProject,
issues: list[str],
photo_dates: dict[str, str],
thumbnails: dict[int, QImage],
) -> None:
self._pending_import_issues = issues
if self._pending_import_mode == "rescan":
selected_page = self._page_at_row(self.pages_list.currentRow())
photo_path_keys = {
_photo_path_key(path) for path in self.project.photo_paths
}
known_keys = {
_photo_path_key(page.image_path)
for page in self.project.pages
if page.image_path
}
for page in (self.project.cover.back_page, self.project.cover.front_page):
if page and page.image_path:
known_keys.add(_photo_path_key(page.image_path))
first_inserted_index: int | None = None
added_count = 0
for page in project.pages:
if not page.image_path:
continue
path_key = _photo_path_key(page.image_path)
if path_key in known_keys:
continue
known_keys.add(path_key)
if path_key not in photo_path_keys:
self.project.photo_paths.append(page.image_path)
photo_path_keys.add(path_key)
index = self._insert_page_chronologically(page)
if first_inserted_index is None or index < first_inserted_index:
first_inserted_index = index
added_count += 1
if id(page) in thumbnails:
self._photo_thumbnail_cache[id(page)] = QPixmap.fromImage(thumbnails[id(page)])
self._photo_date_cache.update(photo_dates)
new_source = self._pending_rescan_folder or project.source_directory
source_changed = bool(new_source and self.project.source_directory != new_source)
if new_source:
self.project.source_directory = new_source
self._pending_rescan_count = added_count
if added_count or source_changed:
self._set_dirty(True)
if added_count:
selected_index = (
self._project_index_for_page(selected_page)
if selected_page is not None
else first_inserted_index
)
self._refresh_page_list(selected_index)
elif source_changed:
self._update_status()
image_word = "image" if added_count == 1 else "images"
self.status_label.setText(
f"Rescan complete: imported {added_count} new {image_word}."
)
return
if not project.photo_paths:
self._pending_empty_import = True
return
self.project = project
_resolve_cover_font_families(self.project.cover)
self.project_path = None
self._photo_date_cache = photo_dates
self._photo_thumbnail_cache = {
page_id: QPixmap.fromImage(image) for page_id, image in thumbnails.items()
}
self._set_dirty(True)
self._refresh_page_list(0)
self.status_label.setText(
f"Imported {len(project.photo_paths)} photo(s), sorted oldest to newest."
)
def _folder_import_failed(self, message: str) -> None:
self._pending_import_error = message
def _folder_import_worker_finished(self) -> None:
worker = self.folder_import_worker
self.folder_import_worker = None
if self.import_progress_dialog:
self.import_progress_dialog.close()
self.import_progress_dialog.deleteLater()
self.import_progress_dialog = None
if worker:
worker.deleteLater()
if self._pending_import_error:
QMessageBox.critical(self, "Import photos", self._pending_import_error)
elif self._pending_import_mode == "rescan":
count = self._pending_rescan_count or 0
image_word = "image" if count == 1 else "images"
QMessageBox.information(
self,
"Rescan complete",
f"Imported {count} new {image_word} into the book.",
)
elif self._pending_empty_import:
QMessageBox.warning(
self,
"Import photos",
"No readable photos were found in that folder.",
)
elif self._pending_import_issues:
preview = "\n".join(self._pending_import_issues[:12])
if len(self._pending_import_issues) > 12:
preview += f"\n…and {len(self._pending_import_issues) - 12} more."
QMessageBox.warning(self, "Some photos could not be read", preview)
if (
self._pending_import_mode == "rescan"
and self._pending_import_issues
and not self._pending_import_error
):
preview = "\n".join(self._pending_import_issues[:12])
if len(self._pending_import_issues) > 12:
preview += f"\n…and {len(self._pending_import_issues) - 12} more."
QMessageBox.warning(self, "Some photos could not be read", preview)
self._pending_import_issues = []
self._pending_import_error = None
self._pending_empty_import = False
self._pending_import_mode = "replace"
self._pending_rescan_folder = None
self._pending_rescan_count = None
def _recent_project_paths(self) -> list[str]:
stored = self.settings.value("recentProjects", [])
if isinstance(stored, str):
candidates = [stored]
elif isinstance(stored, (list, tuple)):
candidates = [str(path) for path in stored]
else:
candidates = []
paths: list[str] = []
seen: set[str] = set()
for candidate in candidates:
try:
normalized = str(Path(candidate).expanduser().resolve())
except OSError:
normalized = candidate
key = normalized.casefold()
if normalized and key not in seen:
paths.append(normalized)
seen.add(key)
return paths
def _remember_project(self, path: str) -> None:
normalized = str(Path(path).expanduser().resolve())
updated = [normalized]
updated.extend(
recent
for recent in self._recent_project_paths()
if recent.casefold() != normalized.casefold()
)
self.settings.setValue("recentProjects", updated[:10])
self._populate_recent_menu()
def _populate_recent_menu(self) -> None:
menu = self.open_recent_menu
menu.clear()
recent = self._recent_project_paths()
if not recent:
empty_action = menu.addAction("No recent projects")
empty_action.setEnabled(False)
else:
name_counts: dict[str, int] = {}
for path in recent:
name = Path(path).name.casefold()
name_counts[name] = name_counts.get(name, 0) + 1
for path in recent:
project_path = Path(path)
label = project_path.name
if name_counts.get(label.casefold(), 0) > 1:
label = f"{label} — {project_path.parent}"
if not project_path.is_file():
label += " (missing)"
action = menu.addAction(label)
action.setToolTip(path)
action.setEnabled(project_path.is_file())
action.triggered.connect(
lambda _checked=False, selected_path=path: self._open_recent_project(
selected_path
)
)
menu.addSeparator()
clear_action = menu.addAction("Clear Recent")
clear_action.setEnabled(bool(recent))
clear_action.triggered.connect(self._clear_recent_projects)
def _clear_recent_projects(self) -> None:
self.settings.setValue("recentProjects", [])
self._populate_recent_menu()
def _load_project(self, path: str) -> bool:
if (
self.project_load_worker and self.project_load_worker.isRunning()
) or (self.folder_import_worker and self.folder_import_worker.isRunning()):
return False
self._pending_project_load_error = None
dialog = QProgressDialog("Loading project…", "", 0, 0, self)
dialog.setWindowTitle("Opening project")
dialog.setWindowModality(Qt.WindowModality.WindowModal)
dialog.setCancelButton(None)
dialog.setMinimumDuration(0)
dialog.setAutoClose(False)
dialog.setAutoReset(False)
dialog.setWindowFlags(dialog.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
self.project_load_dialog = dialog
worker = ProjectLoadWorker(path)
self.project_load_worker = worker
worker.stageChanged.connect(self._project_load_stage_changed)
worker.progressChanged.connect(self._project_load_progress_changed)
worker.completed.connect(self._project_load_completed)
worker.failed.connect(self._project_load_failed)
worker.finished.connect(self._project_load_worker_finished)
dialog.show()
worker.start()
return True
def _project_load_stage_changed(self, message: str, total: int) -> None:
if not self.project_load_dialog:
return
self.project_load_dialog.setLabelText(message)
self.project_load_dialog.setRange(0, total)
self.project_load_dialog.setValue(0)
def _project_load_progress_changed(self, completed: int, total: int) -> None:
if self.project_load_dialog:
self.project_load_dialog.setLabelText(
f"Preparing project photo previews… {completed} of {total}"
)
self.project_load_dialog.setValue(completed)
def _project_load_completed(
self,
project: BookProject,
photo_dates: dict[str, str],
thumbnails: dict[int, QImage],
) -> None:
self.project = project
_resolve_cover_font_families(self.project.cover)
self.project_path = str(Path(self.project_load_worker.path).expanduser().resolve())
self._photo_date_cache = photo_dates
self._photo_thumbnail_cache = {
page_id: QPixmap.fromImage(image) for page_id, image in thumbnails.items()
}
self._set_dirty(False)
self._refresh_page_list(0)
self._remember_project(self.project_path)
def _project_load_failed(self, message: str) -> None:
self._pending_project_load_error = message
def _project_load_worker_finished(self) -> None:
worker = self.project_load_worker
self.project_load_worker = None
if self.project_load_dialog:
self.project_load_dialog.close()
self.project_load_dialog.deleteLater()
self.project_load_dialog = None
if worker:
worker.deleteLater()
if self._pending_project_load_error:
QMessageBox.critical(
self,
"Open project",
f"Could not open the project:\n{self._pending_project_load_error}",
)
self._pending_project_load_error = None
def _open_project(self) -> None:
if not self._maybe_save():
return
path, _ = QFileDialog.getOpenFileName(
self, "Open photo book project", str(Path.home()), "Blurb projects (*.blurbproj);;JSON files (*.json)"
)
if path:
self._load_project(path)
def _open_recent_project(self, path: str) -> None:
if self._maybe_save():
self._load_project(path)
def _save_project(self) -> bool:
if self.project_path is None:
return self._save_project_as()
try:
self.project.save(self.project_path)
except OSError as exc:
QMessageBox.critical(self, "Save project", str(exc))
return False
self._set_dirty(False)
self._remember_project(self.project_path)
self.status_label.setText(f"Saved project: {self.project_path}")
return True
def _save_project_as(self) -> bool:
path, _ = QFileDialog.getSaveFileName(
self,
"Save photo book project",
self.project_path or str(Path(self.project.source_directory or Path.home()) / "photo-book.blurbproj"),
"Blurb projects (*.blurbproj)",
)
if not path:
return False
if not path.lower().endswith(".blurbproj"):
path += ".blurbproj"
old_path = self.project_path
self.project_path = path
if not self._save_project():
self.project_path = old_path
return False
return True
def _edit_print_setup(self) -> None:
dialog = PrintSetupDialog(self.project.profile, self)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
self.project.profile = dialog.profile
self._set_dirty(True)
self._refresh_canvas()
self._sync_zoom_control()
self._update_status()
def _export_pdf(self) -> None:
if (self.worker and self.worker.isRunning()) or (
self.cover_export_worker and self.cover_export_worker.isRunning()
):
return
if not self.project.pages:
QMessageBox.information(self, "Export PDF", "Import photos or insert a page first.")
return
if not self.project.profile.ready:
QMessageBox.information(
self,
"Blurb print setup required",
"Enter the final exported page dimensions and trim offsets from Blurb’s specifications first.",
)
self._edit_print_setup()
if not self.project.profile.ready:
return
errors = self.project.profile.validate()
if errors:
QMessageBox.warning(self, "Blurb print setup", "\n".join(errors))
return
if self.project.cover.has_content:
cover_errors = self.project.cover.validate()
if cover_errors:
QMessageBox.warning(self, "Cover setup", "\n".join(cover_errors))
return
count = len(self.project.pages)
if count > self.project.profile.max_pages:
QMessageBox.warning(
self,
"Page limit exceeded",
f"This Blurb format allows at most {self.project.profile.max_pages} pages. "
"Remove pages or update the limit from the selected Blurb specifications.",
)
return
target_count = max(count, self.project.profile.min_pages)
if target_count % 2:
target_count += 1
if target_count > self.project.profile.max_pages:
QMessageBox.warning(
self,
"Page limits conflict",
"The minimum page count and even-page rule exceed the maximum for this format. "
"Review the Blurb page limits in print setup.",
)
return
if target_count > count:
add_count = target_count - count
box = QMessageBox(self)
box.setWindowTitle("Blurb page count")
box.setText(
f"This format requires an even page count between {self.project.profile.min_pages} "
f"and {self.project.profile.max_pages}. Append {add_count} blank page(s) to continue?"
)
append = box.addButton(
f"Append {add_count} blank page(s)", QMessageBox.ButtonRole.AcceptRole
)
box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole)
box.exec()
if box.clickedButton() is not append:
return
self.project.pages.extend(PhotoPage() for _ in range(add_count))
self._set_dirty(True)
self._refresh_page_list(len(self.project.pages) - 1)
suggested = Path(self.project.source_directory or Path.home()) / "blurb-book.pdf"
output, _ = QFileDialog.getSaveFileName(self, "Export Blurb PDF", str(suggested), "PDF files (*.pdf)")
if not output:
return
if not output.lower().endswith(".pdf"):
output += ".pdf"
if self.project.cover.has_content:
interior_destination = Path(output)
cover_destination = interior_destination.with_name(
f"{interior_destination.stem}-cover.pdf"
)
if cover_destination.exists():
overwrite = QMessageBox.question(
self,
"Replace cover PDF",
f"The companion cover PDF already exists:\n{cover_destination}\n\nReplace it?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if overwrite != QMessageBox.StandardButton.Yes:
return
snapshot = copy.deepcopy(self.project)
self.worker = ExportWorker(snapshot, output)
self.worker.progressChanged.connect(self._export_progress)
self.worker.succeeded.connect(self._export_succeeded)
self.worker.failed.connect(self._export_failed)
self.worker.start()
self.export_action.setEnabled(False)
self.export_cover_action.setEnabled(False)
total_work = len(snapshot.pages) + (1 if snapshot.cover.has_content else 0)
self.progress.setRange(0, total_work)
self.progress.setValue(0)
self.progress.setVisible(True)
self.status_label.setText("Rendering pages…")
def _export_cover_pdf(self) -> None:
if (self.worker and self.worker.isRunning()) or (
self.cover_export_worker and self.cover_export_worker.isRunning()
):
return
if not self.project.cover.has_content:
QMessageBox.information(
self,
"Export cover PDF",
"Add a cover photo or spine text before exporting the cover.",
)
return
cover_errors = self.project.cover.validate()
if cover_errors:
QMessageBox.warning(self, "Cover setup", "\n".join(cover_errors))
return
project_path = Path(self.project_path) if self.project_path else None
output_directory = (
project_path.parent
if project_path
else Path(self.project.source_directory or Path.home())
)
base_name = project_path.stem if project_path else "blurb-book"
suggested = output_directory / f"{base_name}-cover.pdf"
output, _ = QFileDialog.getSaveFileName(
self,
"Export Blurb Cover PDF",
str(suggested),
"PDF files (*.pdf)",
)
if not output:
return
if not output.lower().endswith(".pdf"):
output += ".pdf"
destination = Path(output)
if destination.exists():
overwrite = QMessageBox.question(
self,
"Replace cover PDF",
f"The cover PDF already exists:\n{destination}\n\nReplace it?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if overwrite != QMessageBox.StandardButton.Yes:
return
snapshot = copy.deepcopy(self.project)
self.cover_export_worker = CoverExportWorker(snapshot, output)
self.cover_export_worker.progressChanged.connect(self._cover_export_progress)
self.cover_export_worker.succeeded.connect(self._cover_export_succeeded)
self.cover_export_worker.failed.connect(self._cover_export_failed)
self.cover_export_worker.start()
self.export_action.setEnabled(False)
self.export_cover_action.setEnabled(False)
self.progress.setRange(0, 0)
self.progress.setVisible(True)
self.status_label.setText("Rendering cover spread…")
def _cover_export_progress(self, done: int, total: int) -> None:
self.progress.setRange(0, total)
self.progress.setValue(done)
self.status_label.setText("Cover PDF verified and saved.")
def _cover_export_succeeded(self, path: str) -> None:
self.export_action.setEnabled(True)
self.export_cover_action.setEnabled(True)
self.progress.setVisible(False)
self.status_label.setText(f"Cover PDF verified and saved: {path}")
QMessageBox.information(self, "Cover PDF ready", f"Cover PDF created and verified:\n\n{path}")
def _cover_export_failed(self, message: str) -> None:
self.export_action.setEnabled(True)
self.export_cover_action.setEnabled(True)
self.progress.setVisible(False)
self.status_label.setText("Cover PDF export failed.")
QMessageBox.critical(self, "Cover PDF export failed", message)
def _export_progress(self, done: int, total: int) -> None:
self.progress.setRange(0, total)
self.progress.setValue(done)
if done > len(self.project.pages):
self.status_label.setText("Verifying cover spread…")
else:
self.status_label.setText(f"Rendering page {done} of {len(self.project.pages)}…")
def _export_succeeded(self, path: str) -> None:
self.export_action.setEnabled(True)
self.export_cover_action.setEnabled(True)
self.progress.setVisible(False)
paths = path.splitlines()
self.status_label.setText(f"PDF verified and saved: {paths[0]}")
QMessageBox.information(self, "PDF ready", "PDF created and verified:\n\n" + "\n".join(paths))
def _export_failed(self, message: str) -> None:
self.export_action.setEnabled(True)
self.export_cover_action.setEnabled(True)
self.progress.setVisible(False)
self.status_label.setText("PDF export failed.")
QMessageBox.critical(self, "PDF export failed", message)
def closeEvent(self, event) -> None: # noqa: N802 - Qt event name
if self.project_load_worker and self.project_load_worker.isRunning():
QMessageBox.information(
self,
"Project loading",
"Wait for the project to finish loading before closing.",
)
event.ignore()
return
if self.folder_import_worker and self.folder_import_worker.isRunning():
QMessageBox.information(
self,
"Photo import running",
"Wait for the photo import to finish before closing.",
)
event.ignore()
return
if self.worker and self.worker.isRunning():
QMessageBox.information(self, "PDF export running", "Wait for the PDF export to finish before closing.")
event.ignore()
return
if self.cover_export_worker and self.cover_export_worker.isRunning():
QMessageBox.information(
self,
"Cover PDF export running",
"Wait for the cover PDF export to finish before closing.",
)
event.ignore()
return
if self._maybe_save():
event.accept()
else:
event.ignore()
def run() -> int:
app = QApplication(sys.argv)
app.setApplicationName("Blurb Photo Book Editor")
app.setStyle("Fusion")
window = PhotoBookWindow()
window.show()
return app.exec()