3144 lines
132 KiB
Python
3144 lines
132 KiB
Python
"""PySide6 desktop interface for assembling photo pages and exporting a PDF."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import copy
|
||
import json
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
import sys
|
||
|
||
from PIL import Image, ImageOps
|
||
from PySide6.QtCore import QThread, Qt, Signal, QSize, QRectF, 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()
|