From f82c3320c471798a5caf081d69af4694422b3c21 Mon Sep 17 00:00:00 2001 From: Ryan Kegel Date: Wed, 7 Oct 2026 17:14:49 -0400 Subject: [PATCH] Update trim dimensions and cover export functionality in Blurb Photo Book Editor --- AGENT.md | 2 +- README.md | 4 +- blurb_book.py | 100 ++++++++++++++++++--------------- blurb_editor.py | 146 +++++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 198 insertions(+), 54 deletions(-) diff --git a/AGENT.md b/AGENT.md index 3d42a26..981116c 100644 --- a/AGENT.md +++ b/AGENT.md @@ -26,7 +26,7 @@ Maintain a Windows desktop photo-book editor that imports a photo folder, arrang - Spine title and subtitle vertical positions are saved in inches from the bottom of the face. The title moves within the full spine; the subtitle stays in the bottom subtitle area. Resetting spine text clears custom positions and restores the centered defaults. - **View Tagged** includes tagged photo pages and is disabled when there are no tagged photos. Page insertion and drag reordering are available in **View All** only. - Folder import scanning, project loading, and thumbnail preparation run in background workers. Keep image decoding and preview work off the UI thread. Create or modify `QPixmap` objects only on the GUI thread; worker results should use thread-safe image data such as `QImage`. Keep progress dialogs and worker lifetime/error handling in sync. -- The default trim area is 9.5 × 8 inches per individual interior page. Final PDF dimensions and trim insets are set separately for the chosen Blurb configuration. Interior export renders at 300 PPI and verifies PDF page dimensions and page count. Configured covers export as a separate one-page full spread with editable face/spine dimensions and 0.125-inch starting values for the wrap/bleed margins; users must verify exact values for their selected Blurb configuration. +- The default trim and PDF page size is 9.625 × 8.25 inches per individual interior page, with zero trim insets. Final PDF dimensions and trim insets can be changed for the chosen Blurb configuration. Interior export renders at 300 PPI and verifies PDF page dimensions and page count. Configured covers export as a separate one-page full spread with editable face/spine dimensions, a 0.57-inch default spine, and zero wrap/bleed margins; users must verify exact values for their selected Blurb configuration. - Do not assume trim dimensions equal final PDF dimensions. Blurb requirements vary by format and paper; consult the current official specifications when changing export guidance. ## Development diff --git a/README.md b/README.md index 9a222bc..0655b0a 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,9 @@ Photo cards show the date as `mm/dd/yyyy`. Dates come from supported timestamps ## Blurb print setup and export -The default trim area is **9.5 × 8 inches per individual page**. In **File → Blurb print setup**, enter the final PDF page width and height and the trim offsets required for the selected Blurb book format, paper, and cover. The exported PDF has one page per book page and includes the configured bleed area. The dashed guide shows the trim area, including alternating binding and outside edges. +The default trim and PDF page size is **9.625 × 8.25 inches per individual page**, with zero trim insets. In **File → Blurb print setup**, enter the final PDF page width and height and the trim offsets required for the selected Blurb book format, paper, and cover. The exported PDF has one page per book page and includes the configured bleed area. The dashed guide shows the trim area, including alternating binding and outside edges. -Export renders photo pages at 300 PPI, checks the resulting PDF page count and dimensions, and requires an even page count. It can append blank pages to meet the configured minimum. The default page limits are 20–440; set them to the limits for the selected format and paper. When cover content is assigned, export also creates a one-page full-spread companion named `-cover.pdf` beside the interior PDF. Cover face defaults are 10.074 × 9 inches, with a 0.46-inch spine. Wrap/bleed margins start at 0.125 inches on each edge; these are editable starting values, so verify the exact dimensions and margins for the chosen Blurb format, paper, and page count before exporting. +Export renders photo pages at 300 PPI, checks the resulting PDF page count and dimensions, and requires an even page count. It can append blank pages to meet the configured minimum. The default page limits are 20–440; set them to the limits for the selected format and paper. When cover content is assigned, export also creates a one-page full-spread companion named `-cover.pdf` beside the interior PDF. Use **File → Export Cover PDF** to export only the cover. Cover face defaults are 10.074 × 9 inches, with a 0.57-inch spine and zero wrap/bleed margins. Front and spine text defaults to MV Boli; if it is unavailable, the app uses Arial or the system sans-serif font. These are starting values; verify the exact dimensions and margins for the chosen Blurb format, paper, and page count before exporting. Blurb's requirements vary by book configuration. Check the current [PDF-to-Book specifications](https://support.blurb.com/hc/en-us/articles/207792946-PDF-to-Book-specifications-and-checklist) and [page-count limits](https://support.blurb.com/hc/en-us/articles/7607055286541-How-many-pages-can-my-book-have) before exporting. diff --git a/blurb_book.py b/blurb_book.py index ffcb9d1..c9bdfae 100644 --- a/blurb_book.py +++ b/blurb_book.py @@ -74,10 +74,10 @@ class PhotoPage: class PrintProfile: """Trim dimensions plus exact final PDF dimensions supplied by Blurb.""" - trim_width_in: float = 9.5 - trim_height_in: float = 8.0 - pdf_width_in: float | None = None - pdf_height_in: float | None = None + trim_width_in: float = 9.625 + trim_height_in: float = 8.25 + pdf_width_in: float | None = 9.625 + pdf_height_in: float | None = 8.25 trim_binding_in: float = 0.0 trim_outside_in: float = 0.0 trim_top_in: float = 0.0 @@ -135,10 +135,18 @@ class PrintProfile: if not data: return cls() return cls( - trim_width_in=_to_float(data.get("trim_width_in"), 9.5), - trim_height_in=_to_float(data.get("trim_height_in"), 8.0), - pdf_width_in=_optional_float(data.get("pdf_width_in")), - pdf_height_in=_optional_float(data.get("pdf_height_in")), + trim_width_in=_to_float(data.get("trim_width_in"), 9.625), + trim_height_in=_to_float(data.get("trim_height_in"), 8.25), + pdf_width_in=( + _optional_float(data.get("pdf_width_in")) + if "pdf_width_in" in data + else 9.625 + ), + pdf_height_in=( + _optional_float(data.get("pdf_height_in")) + if "pdf_height_in" in data + else 8.25 + ), trim_binding_in=_to_float( data.get("trim_binding_in", data.get("trim_left_in")), 0.0 ), @@ -147,8 +155,8 @@ class PrintProfile: "trim_outside_in", max( 0.0, - _to_float(data.get("pdf_width_in"), 9.5) - - _to_float(data.get("trim_width_in"), 9.5) + _to_float(data.get("pdf_width_in"), 9.625) + - _to_float(data.get("trim_width_in"), 9.625) - _to_float(data.get("trim_left_in"), 0.0), ), ), @@ -171,28 +179,28 @@ class CoverLayout: subtitle: str = "" front_title: str = "" front_subtitle: str = "" - front_font_family: str = "Arial" - front_title_font_size_pt: float = 36.0 - front_subtitle_font_size_pt: float = 20.0 - front_title_x: float = 0.5 - front_title_y: float = 0.17 - front_subtitle_x: float = 0.5 - front_subtitle_y: float = 0.28 - title_font_family: str = "Arial" - title_font_size_pt: float = 10.0 + front_font_family: str = "MV Boli" + front_title_font_size_pt: float = 48.0 + front_subtitle_font_size_pt: float = 24.0 + front_title_x: float = 0.6511959815448585 + front_title_y: float = 0.22590834010644723 + front_subtitle_x: float = 0.7505046203361379 + front_subtitle_y: float = 0.3083317165255237 + title_font_family: str = "MV Boli" + title_font_size_pt: float = 20.0 title_color: str = "#fff03f" - subtitle_font_family: str = "Arial" - subtitle_font_size_pt: float = 8.0 + subtitle_font_family: str = "MV Boli" + subtitle_font_size_pt: float = 14.0 subtitle_color: str = "#fff03f" spine_title_y_in: float | None = None - spine_subtitle_y_in: float | None = None + spine_subtitle_y_in: float | None = 1.1093844419391201 face_width_in: float = 10.074 face_height_in: float = 9.0 - spine_width_in: float = 0.46 - margin_left_in: float | None = 0.125 - margin_right_in: float | None = 0.125 - margin_top_in: float | None = 0.125 - margin_bottom_in: float | None = 0.125 + spine_width_in: float = 0.57 + margin_left_in: float | None = 0.0 + margin_right_in: float | None = 0.0 + margin_top_in: float | None = 0.0 + margin_bottom_in: float | None = 0.0 @property def has_content(self) -> bool: @@ -368,28 +376,32 @@ class CoverLayout: subtitle=str(data.get("subtitle", "")), front_title=str(data.get("front_title", "")), front_subtitle=str(data.get("front_subtitle", "")), - front_font_family=str(data.get("front_font_family") or "Arial"), - front_title_font_size_pt=_to_float(data.get("front_title_font_size_pt"), 36.0), - front_subtitle_font_size_pt=_to_float(data.get("front_subtitle_font_size_pt"), 20.0), - front_title_x=_to_float(data.get("front_title_x"), 0.5), - front_title_y=_to_float(data.get("front_title_y"), 0.17), - front_subtitle_x=_to_float(data.get("front_subtitle_x"), 0.5), - front_subtitle_y=_to_float(data.get("front_subtitle_y"), 0.28), - title_font_family=str(data.get("title_font_family") or "Arial"), - title_font_size_pt=_to_float(data.get("title_font_size_pt"), 10.0), + front_font_family=str(data.get("front_font_family") or "MV Boli"), + front_title_font_size_pt=_to_float(data.get("front_title_font_size_pt"), 48.0), + front_subtitle_font_size_pt=_to_float(data.get("front_subtitle_font_size_pt"), 24.0), + front_title_x=_to_float(data.get("front_title_x"), 0.6511959815448585), + front_title_y=_to_float(data.get("front_title_y"), 0.22590834010644723), + front_subtitle_x=_to_float(data.get("front_subtitle_x"), 0.7505046203361379), + front_subtitle_y=_to_float(data.get("front_subtitle_y"), 0.3083317165255237), + title_font_family=str(data.get("title_font_family") or "MV Boli"), + title_font_size_pt=_to_float(data.get("title_font_size_pt"), 20.0), title_color=str(data.get("title_color") or "#fff03f"), - subtitle_font_family=str(data.get("subtitle_font_family") or "Arial"), - subtitle_font_size_pt=_to_float(data.get("subtitle_font_size_pt"), 8.0), + subtitle_font_family=str(data.get("subtitle_font_family") or "MV Boli"), + subtitle_font_size_pt=_to_float(data.get("subtitle_font_size_pt"), 14.0), subtitle_color=str(data.get("subtitle_color") or "#fff03f"), spine_title_y_in=_optional_float(data.get("spine_title_y_in")), - spine_subtitle_y_in=_optional_float(data.get("spine_subtitle_y_in")), + spine_subtitle_y_in=( + _optional_float(data.get("spine_subtitle_y_in")) + if "spine_subtitle_y_in" in data + else 1.1093844419391201 + ), face_width_in=_to_float(data.get("face_width_in"), 10.074), face_height_in=_to_float(data.get("face_height_in"), 9.0), - spine_width_in=_to_float(data.get("spine_width_in"), 0.46), - margin_left_in=_to_float(data.get("margin_left_in"), 0.125), - margin_right_in=_to_float(data.get("margin_right_in"), 0.125), - margin_top_in=_to_float(data.get("margin_top_in"), 0.125), - margin_bottom_in=_to_float(data.get("margin_bottom_in"), 0.125), + spine_width_in=_to_float(data.get("spine_width_in"), 0.57), + margin_left_in=_to_float(data.get("margin_left_in"), 0.0), + margin_right_in=_to_float(data.get("margin_right_in"), 0.0), + margin_top_in=_to_float(data.get("margin_top_in"), 0.0), + margin_bottom_in=_to_float(data.get("margin_bottom_in"), 0.0), ) diff --git a/blurb_editor.py b/blurb_editor.py index 1cdf4c3..385396a 100644 --- a/blurb_editor.py +++ b/blurb_editor.py @@ -10,7 +10,7 @@ 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, QFontMetricsF, QImage, QPainter, QPainterPath, QPen, QPixmap +from PySide6.QtGui import QAction, QActionGroup, QColor, QFont, QFontDatabase, QFontMetricsF, QImage, QPainter, QPainterPath, QPen, QPixmap from PySide6.QtWidgets import ( QAbstractItemView, QApplication, @@ -90,6 +90,18 @@ def _photo_path_key(path: str | Path) -> str: 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() @@ -1090,6 +1102,28 @@ class ExportWorker(QThread): 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) @@ -1247,6 +1281,7 @@ class PhotoBookWindow(QMainWindow): 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] = {} @@ -1257,6 +1292,7 @@ class PhotoBookWindow(QMainWindow): 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 @@ -1331,12 +1367,12 @@ class PhotoBookWindow(QMainWindow): self.front_font_combo.currentFontChanged.connect( lambda font: self._front_font_changed(font.family()) ) - self.front_title_size_spin = self._front_font_size_spin(36.0) + 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(20.0) + 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) @@ -1344,7 +1380,7 @@ class PhotoBookWindow(QMainWindow): 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", 10.0), ("subtitle", 8.0)): + for role, default_size in (("title", 20.0), ("subtitle", 14.0)): font_combo = QFontComboBox() font_combo.setMinimumWidth(125) font_combo.setMaximumWidth(185) @@ -1373,7 +1409,7 @@ class PhotoBookWindow(QMainWindow): 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.46) + 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] = {} @@ -1486,7 +1522,7 @@ class PhotoBookWindow(QMainWindow): cover_form.addRow("Wrap/bleed margins", margins_row) cover_layout.addLayout(cover_form) cover_margin_hint = QLabel( - "Starting margins are 0.125 in on each edge. Verify the exact wrap/bleed values for your Blurb format, paper, and page count." + "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;") @@ -1561,6 +1597,9 @@ class PhotoBookWindow(QMainWindow): 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) @@ -2597,6 +2636,7 @@ class PhotoBookWindow(QMainWindow): 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 = { @@ -2773,6 +2813,7 @@ class PhotoBookWindow(QMainWindow): 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 = { @@ -2857,7 +2898,9 @@ class PhotoBookWindow(QMainWindow): self._update_status() def _export_pdf(self) -> None: - if self.worker and self.worker.isRunning(): + 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.") @@ -2948,12 +2991,91 @@ class PhotoBookWindow(QMainWindow): 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) @@ -2964,6 +3086,7 @@ class PhotoBookWindow(QMainWindow): 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]}") @@ -2971,6 +3094,7 @@ class PhotoBookWindow(QMainWindow): 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) @@ -2996,6 +3120,14 @@ class PhotoBookWindow(QMainWindow): 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: