From 52a497e9722ec8217751e2b66aaf47dc4197ddde Mon Sep 17 00:00:00 2001 From: Ryan Kegel Date: Wed, 7 Oct 2026 16:12:39 -0400 Subject: [PATCH] Refactor code structure for improved readability and maintainability --- AGENT.md | 16 +- README.md | 12 +- blurb_book.py | 606 +++++++++++++++- blurb_editor.py | 1770 +++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 2332 insertions(+), 72 deletions(-) diff --git a/AGENT.md b/AGENT.md index 5b9a0ff..3d42a26 100644 --- a/AGENT.md +++ b/AGENT.md @@ -2,13 +2,13 @@ ## Project purpose -Maintain a Windows desktop photo-book editor that imports a photo folder, arranges one photo per page, edits photo crops, and exports an interior PDF for Blurb. +Maintain a Windows desktop photo-book editor that imports a photo folder, arranges one photo per interior page, edits photo crops, and exports Blurb interior and optional ImageWrap cover PDFs. ## Project map - `main.py` launches the app. -- `blurb_editor.py` implements the PySide6 UI, page list and canvas, and the background import/export workers. -- `blurb_book.py` defines `PhotoPage`, `BookProject`, and `PrintProfile`; it also handles photo sorting, crop rendering, and PDF export. +- `blurb_editor.py` implements the PySide6 UI, page and cover lists/canvases, and the background import/export workers. +- `blurb_book.py` defines `PhotoPage`, `CoverLayout`, `BookProject`, and `PrintProfile`; it also handles photo sorting, crop rendering, and PDF export. - `requirements.txt` pins runtime dependencies. - `README.md` is the user-facing setup and feature guide. @@ -16,13 +16,17 @@ Maintain a Windows desktop photo-book editor that imports a photo folder, arrang - Folder import is top-level only. Supported formats are JPEG, PNG, TIFF, WebP, and BMP. HEIC and camera RAW formats are reported as unsupported. - Photos sort by recognized filename timestamp first, then EXIF capture time, then filesystem creation time. +- Rescanning a selected folder adds only paths not already in the project, preserves existing page edits and relative order, inserts new pages chronologically, and reports the number added. - New photo pages start with centered cover placement (`zoom=1`, `pan_x=0`, `pan_y=0`). Keep preview and export rendering aligned through `render_photo` and the shared crop geometry helpers. -- Photo sources are read-only. Projects store absolute paths, page order, crop transforms, tags, and print settings. +- Photo sources are read-only. Projects store absolute paths, page order, crop transforms, tags, print settings, cover-spine text styles and vertical positions, and front-cover text settings and positions. - `PhotoPage.tagged` is optional in older project files; missing values must continue to load as untagged. - The page list is canonical in `BookProject.pages`. In **View Tagged**, list rows are only a filtered subset. Never treat a visible row number as the project page index; use the page object and the `_page_at_row` / `_project_index_for_page` helpers. Preserve full-book page numbering while filtering. +- Cover photos live in `BookProject.cover`, outside `BookProject.pages`. The **Cover Photos** list is informational; cover photos are not interior pages, page-count entries, or reorderable page rows. Removing a cover returns its `PhotoPage` to `BookProject.pages` by capture date. +- Front-cover title and subtitle text use one saved font family, separate sizes and movable normalized positions. Each uses the corresponding spine text color and a 7 px black outline radius; cover export rasterizes the lettering into the spread image. +- 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 and thumbnail preparation run in `FolderImportWorker`. 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 the progress dialog and worker lifetime/error handling in sync. -- The default trim area is 9.5 × 8 inches per individual page. Final PDF dimensions and trim insets are set separately for the chosen Blurb configuration. Export contains interior pages only, renders at 300 PPI, and verifies PDF page dimensions and page count. +- 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. - 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 644143c..9a222bc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Blurb Photo Book Editor -A Windows desktop app for arranging a folder of photos into a Blurb-ready interior PDF. Each photo is placed on its own page. The editor supports reordering, crop adjustment, page tags, and print-size setup. +A Windows desktop app for arranging a folder of photos into a Blurb-ready interior PDF and ImageWrap cover spread. Each interior photo is placed on its own page. The editor supports reordering, crop adjustment, page tags, and print-size setup. ## Install and launch @@ -15,10 +15,12 @@ python -m venv .venv ## Use the editor 1. Choose **File → Import photo folder**. Import scans the selected folder's top level, sorts photos oldest to newest, and prepares previews in the background. A progress dialog shows scanning and preview progress. + To add photos later, choose **File → Rescan Folder for New Photos**. It adds images not already in the project at their chronological positions, preserves existing page edits, and reports how many were added. 2. Select a page card to edit it. Drag a photo inside the page to reposition it; use the zoom slider or +/- buttons to zoom around the current frame center. Photos start centered and cropped to fill the page. 3. Drag cards to reorder pages. Use **Insert blank**, **Insert photo**, **Remove page**, or **Relink photo** as needed. The source photos are not modified. -4. Right-click a page card to **Tag page** or **Untag page**. A red badge marks tagged pages, and tags are saved in the project. **View → View Tagged** filters the list to tagged photo pages; it is disabled when no photos are tagged. In this view, insertion and reordering are disabled; switch to **View All** to use them. -5. Save the editable `.blurbproj` separately from the exported PDF. +4. Right-click a photo page to **Make Front Cover** or **Make Back Cover**. The page leaves the interior and appears in **Cover Photos** with its cover-side badge. In **View → Cover Editor**, adjust its crop, enter spine text, and add optional front-cover title and subtitle text. Drag spine text up or down and front-cover text in any direction. **View → Reset spine text positions** restores the default spine layout. The front text uses the shared font and separate size controls, a 7 px radius black outline, and the matching spine text color. Set cover dimensions and wrap/bleed margins here as well. Remove a cover to return its photo to the interior in date order. Dashed spine guides show the full title area and the bottom 3.504-inch subtitle area. +5. Right-click a page card to **Tag page** or **Untag page**. A red badge marks tagged pages, and tags are saved in the project. **View → View Tagged** filters the list to tagged photo pages; it is disabled when no photos are tagged. In this view, insertion and reordering are disabled; switch to **View All** to use them. +6. Save the editable `.blurbproj` separately from the exported PDFs. **File → Open Recent** keeps up to ten projects you opened or saved so you can return to them later. Opening a project shows progress while it loads and prepares photo previews. Photo cards show the date as `mm/dd/yyyy`. Dates come from supported timestamps in the filename first, then EXIF capture date, then filesystem creation time as a fallback. @@ -26,7 +28,7 @@ Photo cards show the date as `mm/dd/yyyy`. Dates come from supported timestamps 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. -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. The PDF contains interior pages only; cover design is not included. +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. 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. @@ -34,7 +36,7 @@ Supported photo formats are JPEG, PNG, TIFF, WebP, and BMP. HEIC and camera RAW ## Project files -A `.blurbproj` file stores photo paths, page order, each page's zoom and pan, page tags, and print settings. Photo files remain at their original locations; if a path is no longer available, use **Relink photo**. +A `.blurbproj` file stores photo paths, page order, each page's zoom and pan, page tags, print settings, cover photos, spine and front-cover text styling and positions, and cover dimensions. Photo files remain at their original locations; if a path is no longer available, use **Relink photo** for interior pages or remove the cover photo and relink it from the book editor. ## Code layout diff --git a/blurb_book.py b/blurb_book.py index 7a74fd7..ffcb9d1 100644 --- a/blurb_book.py +++ b/blurb_book.py @@ -17,12 +17,14 @@ from PIL import Image, ImageOps from reportlab.pdfgen import canvas -PROJECT_VERSION = 1 +PROJECT_VERSION = 2 SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp", ".bmp"} EXPORT_PPI = 300 JPEG_QUALITY = 94 MAX_PAGE_INCHES = 30.0 MAX_RENDER_PIXELS = 50_000_000 +SPINE_SUBTITLE_ZONE_HEIGHT_IN = 3.504 +FRONT_COVER_OUTLINE_RADIUS_PX = 7.0 _FILENAME_DATE_PATTERNS = ( re.compile(r"PXL_(20\d{6})_(\d{6})", re.IGNORECASE), @@ -159,11 +161,244 @@ class PrintProfile: ) +@dataclass +class CoverLayout: + """ImageWrap cover artwork and the full-spread print geometry.""" + + back_page: PhotoPage | None = None + front_page: PhotoPage | None = None + title: str = "" + 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 + title_color: str = "#fff03f" + subtitle_font_family: str = "Arial" + subtitle_font_size_pt: float = 8.0 + subtitle_color: str = "#fff03f" + spine_title_y_in: float | None = None + spine_subtitle_y_in: float | None = None + 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 + + @property + def has_content(self) -> bool: + return bool( + self.back_page + or self.front_page + or self.title.strip() + or self.subtitle.strip() + or self.front_title.strip() + or self.front_subtitle.strip() + ) + + def validate(self) -> list[str]: + errors: list[str] = [] + for label, value in ( + ("Cover face width", self.face_width_in), + ("Cover face height", self.face_height_in), + ("Spine width", self.spine_width_in), + ): + if not math.isfinite(value) or value <= 0: + errors.append(f"{label} must be greater than zero.") + elif value > MAX_PAGE_INCHES: + errors.append(f"{label} must be no more than {MAX_PAGE_INCHES:g} inches.") + + if (self.title.strip() or self.subtitle.strip()) and self.spine_width_in * 72 * 0.8 < 3: + errors.append("Spine width is too narrow for readable title or subtitle text.") + + for label, family, size, color in ( + ("Title", self.title_font_family, self.title_font_size_pt, self.title_color), + ("Subtitle", self.subtitle_font_family, self.subtitle_font_size_pt, self.subtitle_color), + ): + if not family.strip(): + errors.append(f"{label} font family cannot be empty.") + if not math.isfinite(size) or not 4 <= size <= 72: + errors.append(f"{label} font size must be between 4 and 72 points.") + if not re.fullmatch(r"#[0-9a-fA-F]{6}", color): + errors.append(f"{label} color must be a six-digit hex color.") + + if not self.front_font_family.strip(): + errors.append("Front cover font family cannot be empty.") + for label, size in ( + ("Front title", self.front_title_font_size_pt), + ("Front subtitle", self.front_subtitle_font_size_pt), + ): + if not math.isfinite(size) or not 4 <= size <= 144: + errors.append(f"{label} font size must be between 4 and 144 points.") + for label, position in ( + ("Front title horizontal position", self.front_title_x), + ("Front title vertical position", self.front_title_y), + ("Front subtitle horizontal position", self.front_subtitle_x), + ("Front subtitle vertical position", self.front_subtitle_y), + ): + if not math.isfinite(position) or not 0 <= position <= 1: + errors.append(f"{label} must be between 0 and 1.") + + spine_title_position = self.spine_title_y_in + if spine_title_position is not None and ( + not math.isfinite(spine_title_position) + or not 0 <= spine_title_position <= self.face_height_in + ): + errors.append("Spine title vertical position must be within the cover face.") + subtitle_zone_height = min(SPINE_SUBTITLE_ZONE_HEIGHT_IN, self.face_height_in) + spine_subtitle_position = self.spine_subtitle_y_in + if spine_subtitle_position is not None and ( + not math.isfinite(spine_subtitle_position) + or not 0 <= spine_subtitle_position <= subtitle_zone_height + ): + errors.append("Spine subtitle vertical position must be within the subtitle area.") + + for label, value in ( + ("Left wrap/bleed margin", self.margin_left_in), + ("Right wrap/bleed margin", self.margin_right_in), + ("Top wrap/bleed margin", self.margin_top_in), + ("Bottom wrap/bleed margin", self.margin_bottom_in), + ): + if value is None: + errors.append(f"Enter the {label.lower()} from the Blurb specifications.") + elif not math.isfinite(value) or value < 0: + errors.append(f"{label} must be a non-negative number.") + elif value > MAX_PAGE_INCHES: + errors.append(f"{label} must be no more than {MAX_PAGE_INCHES:g} inches.") + + if not errors: + spread_width, spread_height = self.spread_size_in + if spread_width > MAX_PAGE_INCHES or spread_height > MAX_PAGE_INCHES: + errors.append( + f"The full cover spread cannot exceed {MAX_PAGE_INCHES:g} inches in either dimension." + ) + return errors + + @property + def spread_size_in(self) -> tuple[float, float]: + return ( + float(self.margin_left_in or 0.0) + + self.face_width_in * 2 + + self.spine_width_in + + float(self.margin_right_in or 0.0), + float(self.margin_top_in or 0.0) + + self.face_height_in + + float(self.margin_bottom_in or 0.0), + ) + + @property + def spine_text_layout(self) -> tuple[float, float, float, float]: + """Return title/subtitle centers (inches from bottom) and max lengths (points).""" + title_y = ( + self.spine_title_y_in + if self.spine_title_y_in is not None + else self.face_height_in / 2 + ) + subtitle_zone_height = min(SPINE_SUBTITLE_ZONE_HEIGHT_IN, self.face_height_in) + subtitle_y = ( + self.spine_subtitle_y_in + if self.spine_subtitle_y_in is not None + else subtitle_zone_height / 2 + ) + title_extent = max( + 1.0, + min(title_y, self.face_height_in - title_y) * 2 * 72, + ) + subtitle_extent = max( + 1.0, + min(subtitle_y, subtitle_zone_height - subtitle_y) * 2 * 72, + ) + if self.title.strip() and self.subtitle.strip(): + separation = max(1.0, abs(title_y - subtitle_y) * 72 - 12) + title_extent = min(title_extent, separation) + subtitle_extent = min(subtitle_extent, separation) + return title_y, subtitle_y, title_extent, subtitle_extent + + def to_dict(self) -> dict[str, object]: + return { + "back_page": self.back_page.to_dict() if self.back_page else None, + "front_page": self.front_page.to_dict() if self.front_page else None, + "title": self.title, + "subtitle": self.subtitle, + "front_title": self.front_title, + "front_subtitle": self.front_subtitle, + "front_font_family": self.front_font_family, + "front_title_font_size_pt": self.front_title_font_size_pt, + "front_subtitle_font_size_pt": self.front_subtitle_font_size_pt, + "front_title_x": self.front_title_x, + "front_title_y": self.front_title_y, + "front_subtitle_x": self.front_subtitle_x, + "front_subtitle_y": self.front_subtitle_y, + "title_font_family": self.title_font_family, + "title_font_size_pt": self.title_font_size_pt, + "title_color": self.title_color, + "subtitle_font_family": self.subtitle_font_family, + "subtitle_font_size_pt": self.subtitle_font_size_pt, + "subtitle_color": self.subtitle_color, + "spine_title_y_in": self.spine_title_y_in, + "spine_subtitle_y_in": self.spine_subtitle_y_in, + "face_width_in": self.face_width_in, + "face_height_in": self.face_height_in, + "spine_width_in": self.spine_width_in, + "margin_left_in": self.margin_left_in, + "margin_right_in": self.margin_right_in, + "margin_top_in": self.margin_top_in, + "margin_bottom_in": self.margin_bottom_in, + } + + @classmethod + def from_dict(cls, data: dict[str, object] | None) -> "CoverLayout": + if not data: + return cls() + back_data = data.get("back_page") + front_data = data.get("front_page") + return cls( + back_page=PhotoPage.from_dict(back_data) if isinstance(back_data, dict) else None, + front_page=PhotoPage.from_dict(front_data) if isinstance(front_data, dict) else None, + title=str(data.get("title", "")), + 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), + 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_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")), + 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), + ) + + @dataclass class BookProject: photo_paths: list[str] = field(default_factory=list) pages: list[PhotoPage] = field(default_factory=list) profile: PrintProfile = field(default_factory=PrintProfile) + cover: CoverLayout = field(default_factory=CoverLayout) source_directory: str | None = None capture_dates: dict[str, datetime] = field(default_factory=dict, repr=False) @@ -184,6 +419,7 @@ class BookProject: "photo_paths": self.photo_paths, "pages": [page.to_dict() for page in self.pages], "print_profile": self.profile.to_dict(), + "cover": self.cover.to_dict(), } def save(self, path: str | Path) -> None: @@ -209,19 +445,23 @@ class BookProject: if not isinstance(data, dict): raise ValueError("The project file does not contain a project object.") version = data.get("version", 1) - if version != PROJECT_VERSION: + if version not in (1, PROJECT_VERSION): raise ValueError(f"Project version {version} is not supported.") photo_paths = data.get("photo_paths", []) pages = data.get("pages", []) profile_data = data.get("print_profile") + cover_data = data.get("cover") if not isinstance(photo_paths, list) or not isinstance(pages, list): raise ValueError("The project file has an invalid photo or page list.") if profile_data is not None and not isinstance(profile_data, dict): raise ValueError("The project file has invalid print settings.") + if cover_data is not None and not isinstance(cover_data, dict): + raise ValueError("The project file has invalid cover settings.") return cls( photo_paths=[str(path_value) for path_value in photo_paths if path_value], pages=[PhotoPage.from_dict(page) for page in pages if isinstance(page, dict)], profile=PrintProfile.from_dict(profile_data), + cover=CoverLayout.from_dict(cover_data), source_directory=( str(data["source_directory"]) if data.get("source_directory") else None ), @@ -571,3 +811,365 @@ def export_pdf( finally: if temp_pdf and os.path.exists(temp_pdf): os.unlink(temp_pdf) + + +def _cover_page_size_pixels(cover: CoverLayout, ppi: int = EXPORT_PPI) -> tuple[int, int]: + errors = cover.validate() + if errors: + raise ValueError("\n".join(errors)) + width_in, height_in = cover.spread_size_in + width = max(1, round(width_in * ppi)) + height = max(1, round(height_in * ppi)) + pixels = width * height + if pixels > MAX_RENDER_PIXELS: + raise ValueError( + f"The cover spread would render to {pixels:,} pixels at {ppi} PPI. " + "Reduce the cover size or margins." + ) + return width, height + + +def _paint_cover_spine_text( + spread: Image.Image, + cover: CoverLayout, + spine_x_px: int, + spine_width_px: int, + face_height_px: int, + top_px: int, +) -> None: + """Render editable system fonts into a transparent 300 PPI spine layer.""" + if not cover.title.strip() and not cover.subtitle.strip(): + return + + from PySide6.QtCore import QRectF, Qt + from PySide6.QtGui import QColor, QFont, QFontMetricsF, QImage, QPainter + + layer = QImage( + max(1, spine_width_px), + max(1, face_height_px), + QImage.Format.Format_RGBA8888, + ) + layer.fill(Qt.GlobalColor.transparent) + painter = QPainter(layer) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setRenderHint(QPainter.RenderHint.TextAntialiasing) + + title_y, subtitle_y, title_extent, subtitle_extent = cover.spine_text_layout + for text, family, size_pt, color, y_from_bottom, max_length_pt in ( + ( + cover.title, + cover.title_font_family, + cover.title_font_size_pt, + cover.title_color, + title_y, + title_extent, + ), + ( + cover.subtitle, + cover.subtitle_font_family, + cover.subtitle_font_size_pt, + cover.subtitle_color, + subtitle_y, + subtitle_extent, + ), + ): + if not text.strip(): + continue + font_px = max(1, round(size_pt * EXPORT_PPI / 72)) + font = QFont(family) + font.setPixelSize(font_px) + measured_px = QFontMetricsF(font).horizontalAdvance(text) + max_length_px = max(1.0, max_length_pt * EXPORT_PPI / 72) + if measured_px > max_length_px: + font_px = round(font_px * max_length_px / measured_px) + if font_px < round(3.0 * EXPORT_PPI / 72): + raise ValueError(f'Spine text "{text}" is too long to fit at its configured position.') + font.setPixelSize(max(1, font_px)) + + painter.save() + painter.translate( + spine_width_px / 2, + face_height_px - y_from_bottom * EXPORT_PPI, + ) + painter.rotate(90) + painter.setFont(font) + painter.setPen(QColor(color)) + text_rect = QRectF( + -max_length_px / 2, + -spine_width_px / 2, + max_length_px, + spine_width_px, + ) + painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, text) + painter.restore() + + painter.end() + image_bytes = bytes(layer.constBits()) + text_layer = Image.frombytes( + "RGBA", + (layer.width(), layer.height()), + image_bytes, + ) + spread.paste(text_layer, (spine_x_px, top_px), text_layer) + text_layer.close() + layer = None + + +def _paint_cover_front_text( + spread: Image.Image, + cover: CoverLayout, + front_x_px: int, + face_width_px: int, + face_height_px: int, + top_px: int, +) -> None: + """Render outlined front-cover titles into the spread image.""" + if not cover.front_title.strip() and not cover.front_subtitle.strip(): + return + + from PySide6.QtCore import QPointF, Qt + from PySide6.QtGui import QColor, QFont, QImage, QPainter, QPainterPath, QPen + + layer = QImage( + max(1, face_width_px), + max(1, face_height_px), + QImage.Format.Format_RGBA8888, + ) + layer.fill(Qt.GlobalColor.transparent) + painter = QPainter(layer) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setRenderHint(QPainter.RenderHint.TextAntialiasing) + + for text, size_pt, x_fraction, y_fraction, color in ( + ( + cover.front_title, + cover.front_title_font_size_pt, + cover.front_title_x, + cover.front_title_y, + cover.title_color, + ), + ( + cover.front_subtitle, + cover.front_subtitle_font_size_pt, + cover.front_subtitle_x, + cover.front_subtitle_y, + cover.subtitle_color, + ), + ): + if not text.strip(): + continue + font_px = max(1, round(size_pt * EXPORT_PPI / 72)) + font = QFont(cover.front_font_family) + font.setPixelSize(font_px) + path = QPainterPath() + path.addText(QPointF(0, 0), font, text) + bounds = path.boundingRect() + max_width_px = max(1.0, face_width_px * 0.9) + if bounds.width() > max_width_px: + font_px = max(1, round(font_px * max_width_px / bounds.width())) + font.setPixelSize(font_px) + path = QPainterPath() + path.addText(QPointF(0, 0), font, text) + bounds = path.boundingRect() + + path.translate( + x_fraction * face_width_px - bounds.center().x(), + y_fraction * face_height_px - bounds.center().y(), + ) + painter.setPen( + QPen( + QColor("#000000"), + FRONT_COVER_OUTLINE_RADIUS_PX * 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(QColor(color)) + painter.drawPath(path) + + painter.end() + image_bytes = bytes(layer.constBits()) + text_layer = Image.frombytes("RGBA", (layer.width(), layer.height()), image_bytes) + spread.paste(text_layer, (front_x_px, top_px), text_layer) + text_layer.close() + layer = None + + +def _verify_cover_pdf(path: Path, width_in: float, height_in: float) -> None: + import pymupdf + + document = pymupdf.open(path) + try: + if len(document) != 1: + raise ValueError(f"Cover PDF verification found {len(document)} pages, expected one.") + rect = document[0].rect + if abs(rect.width - width_in * 72) > 0.15 or abs(rect.height - height_in * 72) > 0.15: + raise ValueError("Cover PDF does not match the configured full-spread dimensions.") + if not document[0].get_images(full=True): + raise ValueError("Cover PDF is missing its spread artwork.") + finally: + document.close() + + +def export_cover_pdf( + project: BookProject, + output_path: str | Path, + progress: Callable[[int, int], None] | None = None, +) -> Path: + """Create and verify a single-page ImageWrap full-spread cover PDF.""" + cover = project.cover + if not cover.has_content: + raise ValueError("Add a cover photo or spine text before exporting the cover.") + page_width_px, page_height_px = _cover_page_size_pixels(cover) + destination = Path(output_path).expanduser().resolve() + destination.parent.mkdir(parents=True, exist_ok=True) + spread_width_in, spread_height_in = cover.spread_size_in + temp_pdf: str | None = None + try: + with tempfile.TemporaryDirectory(prefix="blurb-cover-render-") as render_directory: + render_root = Path(render_directory) + spread = Image.new("RGB", (page_width_px, page_height_px), "white") + left_px = round(float(cover.margin_left_in) * EXPORT_PPI) + back_width_px = round(cover.face_width_in * EXPORT_PPI) + spine_width_px = round(cover.spine_width_in * EXPORT_PPI) + face_height_px = round(cover.face_height_in * EXPORT_PPI) + top_px = round(float(cover.margin_top_in) * EXPORT_PPI) + bottom_px = round(float(cover.margin_bottom_in) * EXPORT_PPI) + right_px = round(float(cover.margin_right_in) * EXPORT_PPI) + back_x_px = left_px + spine_x_px = back_x_px + back_width_px + front_x_px = spine_x_px + spine_width_px + spine_art = Image.new( + "RGB", + (max(1, spine_width_px), page_height_px), + (38, 59, 80), + ) + spread.paste(spine_art, (spine_x_px, 0)) + spine_art.close() + + rendered_panels: list[tuple[str, Image.Image, int]] = [] + for label, page, x_px in ( + ("back", cover.back_page, back_x_px), + ("front", cover.front_page, front_x_px), + ): + if page is None or not page.image_path: + continue + image_path = Path(page.image_path) + if not image_path.is_file(): + raise FileNotFoundError(f"Cover photo is missing: {image_path}") + panel = render_photo( + image_path, + (back_width_px, face_height_px), + page.zoom, + page.pan_x, + page.pan_y, + ) + spread.paste(panel, (x_px, top_px)) + rendered_panels.append((label, panel, x_px)) + + # Extend the outer and vertical face edges into the configured wrap areas. + # This gives edge-to-edge artwork rather than leaving the added print area white. + for label, panel, x_px in rendered_panels: + panel_w, panel_h = panel.size + if top_px: + top_edge = panel.crop((0, 0, panel_w, 1)).resize( + (panel_w, top_px), Image.Resampling.BILINEAR + ) + spread.paste(top_edge, (x_px, 0)) + top_edge.close() + if bottom_px: + bottom_edge = panel.crop((0, panel_h - 1, panel_w, panel_h)).resize( + (panel_w, bottom_px), Image.Resampling.BILINEAR + ) + spread.paste(bottom_edge, (x_px, top_px + face_height_px)) + bottom_edge.close() + if label == "back" and left_px: + left_edge = panel.crop((0, 0, 1, panel_h)).resize( + (left_px, panel_h), Image.Resampling.BILINEAR + ) + spread.paste(left_edge, (0, top_px)) + left_edge.close() + if top_px: + spread.paste(panel.getpixel((0, 0)), (0, 0, left_px, top_px)) + if bottom_px: + spread.paste( + panel.getpixel((0, panel_h - 1)), + (0, top_px + face_height_px, left_px, page_height_px), + ) + if label == "front" and right_px: + right_edge = panel.crop((panel_w - 1, 0, panel_w, panel_h)).resize( + (right_px, panel_h), Image.Resampling.BILINEAR + ) + spread.paste(right_edge, (front_x_px + panel_w, top_px)) + right_edge.close() + if top_px: + spread.paste( + panel.getpixel((panel_w - 1, 0)), + (front_x_px + panel_w, 0, page_width_px, top_px), + ) + if bottom_px: + spread.paste( + panel.getpixel((panel_w - 1, panel_h - 1)), + ( + front_x_px + panel_w, + top_px + face_height_px, + page_width_px, + page_height_px, + ), + ) + panel.close() + + _paint_cover_spine_text( + spread, + cover, + spine_x_px, + spine_width_px, + face_height_px, + top_px, + ) + _paint_cover_front_text( + spread, + cover, + front_x_px, + back_width_px, + face_height_px, + top_px, + ) + rendered_path = render_root / "cover-spread.jpg" + spread.save(rendered_path, format="JPEG", quality=JPEG_QUALITY, optimize=True) + spread.close() + + with tempfile.NamedTemporaryFile( + suffix=".pdf", prefix=f".{destination.stem}-", dir=destination.parent, delete=False + ) as stream: + temp_pdf = stream.name + pdf = canvas.Canvas( + temp_pdf, + pagesize=(spread_width_in * 72, spread_height_in * 72), + pageCompression=1, + ) + pdf.setTitle(destination.stem) + pdf.setCreator("Blurb Photo Book Editor") + pdf.drawImage( + str(rendered_path), + 0, + 0, + width=spread_width_in * 72, + height=spread_height_in * 72, + ) + pdf.showPage() + pdf.save() + + _verify_cover_pdf(Path(temp_pdf), spread_width_in, spread_height_in) + os.replace(temp_pdf, destination) + temp_pdf = None + if progress: + progress(1, 1) + return destination + finally: + if temp_pdf and os.path.exists(temp_pdf): + os.unlink(temp_pdf) diff --git a/blurb_editor.py b/blurb_editor.py index f7de9ad..1cdf4c3 100644 --- a/blurb_editor.py +++ b/blurb_editor.py @@ -9,16 +9,18 @@ from pathlib import Path import sys from PIL import Image, ImageOps -from PySide6.QtCore import QThread, Qt, Signal, QSize, QRectF -from PySide6.QtGui import QAction, QActionGroup, QColor, QFont, QImage, QPainter, QPen, QPixmap +from PySide6.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.QtWidgets import ( QAbstractItemView, QApplication, + QColorDialog, QDialog, QDialogButtonBox, QDoubleSpinBox, QFileDialog, QFormLayout, + QFontComboBox, QHBoxLayout, QLabel, QMainWindow, @@ -30,6 +32,7 @@ from PySide6.QtWidgets import ( QSlider, QSpinBox, QSplitter, + QStackedWidget, QToolBar, QToolButton, QVBoxLayout, @@ -42,14 +45,20 @@ from PySide6.QtWidgets import ( 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, ) @@ -72,6 +81,15 @@ def _photo_size(path: str) -> tuple[int, int]: 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() + + class PageListWidget(QListWidget): orderChanged = Signal() @@ -295,6 +313,643 @@ def _cover_geometry_for_ui(source_size, page_size, zoom, pan_x, pan_y): 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) @@ -403,14 +1058,35 @@ class ExportWorker(QThread): 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: - result = export_pdf( + interior_path = export_pdf( self.project, self.output_path, - progress=lambda done, total: self.progressChanged.emit(done, total), + progress=lambda done, total: self.progressChanged.emit(done, total_work), ) - self.succeeded.emit(str(result)) + 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)) @@ -420,15 +1096,41 @@ class FolderImportWorker(QThread): completed = Signal(object, object, object, object) failed = Signal(str) - def __init__(self, folder: str) -> None: + 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: - project, issues = BookProject.from_folder(self.folder) + 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 photo previews…", total) + 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) @@ -480,6 +1182,65 @@ class FolderImportWorker(QThread): 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__() @@ -487,26 +1248,45 @@ class PhotoBookWindow(QMainWindow): self.resize(1480, 920) self.project = BookProject() 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.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) @@ -529,6 +1309,81 @@ class PhotoBookWindow(QMainWindow): 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(36.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.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", 10.0), ("subtitle", 8.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.46) + 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) @@ -536,6 +1391,10 @@ class PhotoBookWindow(QMainWindow): 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) @@ -545,10 +1404,104 @@ class PhotoBookWindow(QMainWindow): 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( + "Starting margins are 0.125 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.canvas, 1) + right_layout.addWidget(self.editor_stack, 1) zoom_controls = QHBoxLayout() zoom_controls.addStretch(1) zoom_controls.addWidget(QLabel("Zoom")) @@ -558,10 +1511,10 @@ class PhotoBookWindow(QMainWindow): zoom_controls.addWidget(self.zoom_label) zoom_controls.addStretch(1) right_layout.addLayout(zoom_controls) - hint = QLabel("Drag inside the page to reposition the photo. Photos start cropped to fill the page.") - hint.setAlignment(Qt.AlignmentFlag.AlignCenter) - hint.setStyleSheet("color: #aab1bd; padding: 3px;") - right_layout.addWidget(hint) + 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) @@ -575,15 +1528,23 @@ class PhotoBookWindow(QMainWindow): 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) @@ -619,6 +1580,24 @@ class PhotoBookWindow(QMainWindow): 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) @@ -628,6 +1607,7 @@ class PhotoBookWindow(QMainWindow): self.open_project_action, self.save_action, self.print_setup_action, + self.cover_editor_action, self.export_action, ): toolbar.addAction(action) @@ -643,6 +1623,284 @@ class PhotoBookWindow(QMainWindow): 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" @@ -667,10 +1925,23 @@ class PhotoBookWindow(QMainWindow): ) missing_text = f" · {missing} missing photo(s)" if missing else "" setup = "Print setup needed" if not self.project.profile.ready else "Print setup ready" - self.status_label.setText(f"{count} pages · {photo_count} photo pages · {setup}{odd}{missing_text}") + 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) @@ -682,7 +1953,10 @@ class PhotoBookWindow(QMainWindow): self.insert_photo_button.setToolTip(insert_tip) missing = bool(selected and selected.image_path and not Path(selected.image_path).is_file()) self.relink_button.setEnabled(missing) - self.zoom_slider.setEnabled(bool(selected and selected.image_path and Path(selected.image_path).is_file())) + 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() @@ -835,6 +2109,11 @@ class PhotoBookWindow(QMainWindow): 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: @@ -874,15 +2153,19 @@ class PhotoBookWindow(QMainWindow): self._update_actions() def _sync_zoom_control(self) -> None: - row = self.pages_list.currentRow() - page = self._page_at_row(row) + 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 - width, height = _page_dimensions(self.project.profile) + 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) @@ -891,13 +2174,17 @@ class PhotoBookWindow(QMainWindow): self.zoom_label.setText(f"{page.zoom * 100:.0f}% of cover") def _zoom_changed(self, value: int) -> None: - row = self.pages_list.currentRow() - page = self._page_at_row(row) + 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 - width, height = _page_dimensions(self.project.profile) + 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( @@ -910,23 +2197,33 @@ class PhotoBookWindow(QMainWindow): ) page.zoom = next_zoom self.zoom_label.setText(f"{page.zoom * 100:.0f}% of cover") - self.canvas.set_page( - page, - self.project.profile, - self._project_index_for_page(page), - len(self.project.pages), - ) + 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() - self._refresh_one_page(row, refresh_thumbnail=True) + 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: - row = self.pages_list.currentRow() - page = self._page_at_row(row) + 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 - width, height = _page_dimensions(self.project.profile) + 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( @@ -939,14 +2236,20 @@ class PhotoBookWindow(QMainWindow): ) page.zoom = next_zoom self._sync_zoom_control() - self.canvas.set_page( - page, - self.project.profile, - self._project_index_for_page(page), - len(self.project.pages), - ) + 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() - self._refresh_one_page(row, refresh_thumbnail=True) + 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() @@ -979,8 +2282,66 @@ class PhotoBookWindow(QMainWindow): 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: @@ -1089,11 +2450,46 @@ class PhotoBookWindow(QMainWindow): 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 - dialog = QProgressDialog("Scanning photo folder…", "", 0, 0, self) - dialog.setWindowTitle("Importing photos") + 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) @@ -1101,7 +2497,11 @@ class PhotoBookWindow(QMainWindow): dialog.setAutoReset(False) dialog.setWindowFlags(dialog.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint) self.import_progress_dialog = dialog - self.folder_import_worker = FolderImportWorker(folder) + self.folder_import_worker = 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) @@ -1119,8 +2519,13 @@ class PhotoBookWindow(QMainWindow): 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"Preparing photo previews… {completed} of {total}" + f"{phase}… {completed} of {total}" ) self.import_progress_dialog.setValue(completed) @@ -1132,6 +2537,62 @@ class PhotoBookWindow(QMainWindow): 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 @@ -1162,6 +2623,14 @@ class PhotoBookWindow(QMainWindow): 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, @@ -1173,9 +2642,165 @@ class PhotoBookWindow(QMainWindow): 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 + 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(): @@ -1183,19 +2808,12 @@ class PhotoBookWindow(QMainWindow): path, _ = QFileDialog.getOpenFileName( self, "Open photo book project", str(Path.home()), "Blurb projects (*.blurbproj);;JSON files (*.json)" ) - if not path: - return - try: - project = BookProject.load(path) - except (OSError, ValueError, json.JSONDecodeError) as exc: - QMessageBox.critical(self, "Open project", f"Could not open the project:\n{exc}") - return - self.project = project - self.project_path = path - self._photo_date_cache.clear() - self._photo_thumbnail_cache.clear() - self._set_dirty(False) - self._refresh_page_list(0) + 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: @@ -1206,6 +2824,7 @@ class PhotoBookWindow(QMainWindow): 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 @@ -1256,6 +2875,11 @@ class PhotoBookWindow(QMainWindow): 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: @@ -1302,6 +2926,21 @@ class PhotoBookWindow(QMainWindow): 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) @@ -1309,7 +2948,8 @@ class PhotoBookWindow(QMainWindow): self.worker.failed.connect(self._export_failed) self.worker.start() self.export_action.setEnabled(False) - self.progress.setRange(0, len(snapshot.pages)) + 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…") @@ -1317,13 +2957,17 @@ class PhotoBookWindow(QMainWindow): def _export_progress(self, done: int, total: int) -> None: self.progress.setRange(0, total) self.progress.setValue(done) - self.status_label.setText(f"Rendering page {done} of {total}…") + 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.progress.setVisible(False) - self.status_label.setText(f"PDF verified and saved: {path}") - QMessageBox.information(self, "PDF ready", f"PDF created and verified:\n{path}") + 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) @@ -1332,6 +2976,14 @@ class PhotoBookWindow(QMainWindow): 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,