From 73c504abb778b287313a7adebba1ff592919fbfe Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Wed, 26 Nov 2025 22:59:42 +0100 Subject: [PATCH] fixed bug in pdf export fixed bug in asset loading --- pyPhotoAlbum/asset_heal_dialog.py | 84 ++++-- pyPhotoAlbum/async_project_loader.py | 4 +- pyPhotoAlbum/mixins/asset_drop.py | 55 ++-- pyPhotoAlbum/mixins/async_loading.py | 33 ++- pyPhotoAlbum/mixins/operations/element_ops.py | 8 +- pyPhotoAlbum/mixins/operations/file_ops.py | 57 +++- pyPhotoAlbum/models.py | 36 +-- pyPhotoAlbum/pdf_exporter.py | 150 +++++----- pyPhotoAlbum/project_serializer.py | 6 +- tests/test_pdf_export.py | 257 +++++++++++++++++- 10 files changed, 510 insertions(+), 180 deletions(-) diff --git a/pyPhotoAlbum/asset_heal_dialog.py b/pyPhotoAlbum/asset_heal_dialog.py index 9593efc..e43535a 100644 --- a/pyPhotoAlbum/asset_heal_dialog.py +++ b/pyPhotoAlbum/asset_heal_dialog.py @@ -78,24 +78,38 @@ class AssetHealDialog(QDialog): self.setLayout(layout) def _scan_missing_assets(self): - """Scan project for missing assets""" + """Scan project for missing assets - only assets in project's assets folder are valid""" from pyPhotoAlbum.models import ImageData self.missing_assets.clear() self.missing_list.clear() - # Check all pages for missing images + # Check all pages for images that need healing + # Images MUST be in the project's assets folder - absolute paths or external paths need healing for page in self.project.pages: for element in page.layout.elements: if isinstance(element, ImageData) and element.image_path: - # Check if path exists - if os.path.isabs(element.image_path): - full_path = element.image_path - else: - full_path = os.path.join(self.project.folder_path, element.image_path) + needs_healing = False + reason = "" - if not os.path.exists(full_path): + # Absolute paths need healing (should be relative to assets/) + if os.path.isabs(element.image_path): + needs_healing = True + reason = "absolute path" + # Paths not starting with assets/ need healing + elif not element.image_path.startswith("assets/"): + needs_healing = True + reason = "not in assets folder" + else: + # Relative path in assets/ - check if file exists + full_path = os.path.join(self.project.folder_path, element.image_path) + if not os.path.exists(full_path): + needs_healing = True + reason = "file missing" + + if needs_healing: self.missing_assets.add(element.image_path) + print(f"Asset needs healing: {element.image_path} ({reason})") # Display missing assets if self.missing_assets: @@ -128,22 +142,14 @@ class AssetHealDialog(QDialog): self.search_list.takeItem(current_row) def _attempt_healing(self): - """Attempt to heal missing assets using search paths""" + """Attempt to heal missing assets by resolving stored paths and using search paths""" from pyPhotoAlbum.models import ImageData, set_asset_resolution_context - if not self.search_paths: - QMessageBox.warning( - self, - "No Search Paths", - "Please add at least one search path before attempting to heal assets." - ) - return - healed_count = 0 imported_count = 0 still_missing = [] - # Update asset resolution context with search paths + # Update asset resolution context with search paths (for rendering after heal) set_asset_resolution_context(self.project.folder_path, self.search_paths) # Build mapping of missing paths to elements @@ -161,19 +167,34 @@ class AssetHealDialog(QDialog): found_path = None filename = os.path.basename(asset_path) - # Search in each search path - for search_path in self.search_paths: - # Try direct match - candidate = os.path.join(search_path, filename) - if os.path.exists(candidate): - found_path = candidate - break + # FIRST: Try to resolve the stored path directly from project folder + # This handles paths like "../../home/user/Photos/image.jpg" + if not os.path.isabs(asset_path): + resolved = os.path.normpath(os.path.join(self.project.folder_path, asset_path)) + if os.path.exists(resolved): + found_path = resolved + print(f"Resolved relative path: {asset_path} → {resolved}") - # Try with same relative path - candidate = os.path.join(search_path, asset_path) - if os.path.exists(candidate): - found_path = candidate - break + # SECOND: If it's an absolute path, check if it exists directly + if not found_path and os.path.isabs(asset_path): + if os.path.exists(asset_path): + found_path = asset_path + print(f"Found at absolute path: {asset_path}") + + # THIRD: Search in user-provided search paths + if not found_path: + for search_path in self.search_paths: + # Try direct match by filename + candidate = os.path.join(search_path, filename) + if os.path.exists(candidate): + found_path = candidate + break + + # Try with same relative path structure + candidate = os.path.join(search_path, asset_path) + if os.path.exists(candidate): + found_path = candidate + break if found_path: healed_count += 1 @@ -223,5 +244,8 @@ class AssetHealDialog(QDialog): QMessageBox.information(self, "Healing Results", message) + # Reset asset resolution context to project folder only (no search paths for rendering) + set_asset_resolution_context(self.project.folder_path) + # Rescan to update the list self._scan_missing_assets() diff --git a/pyPhotoAlbum/async_project_loader.py b/pyPhotoAlbum/async_project_loader.py index 4b171fd..8ee9d2f 100644 --- a/pyPhotoAlbum/async_project_loader.py +++ b/pyPhotoAlbum/async_project_loader.py @@ -169,8 +169,8 @@ class AsyncProjectLoader(QThread): self.progress_updated.emit(95, 100, "Setting up asset resolution...") # Set asset resolution context - zip_directory = os.path.dirname(os.path.abspath(self.zip_path)) - set_asset_resolution_context(extract_to, additional_search_paths=[zip_directory]) + # Only set project folder - search paths are reserved for healing functionality + set_asset_resolution_context(extract_to) if self._cancelled: return diff --git a/pyPhotoAlbum/mixins/asset_drop.py b/pyPhotoAlbum/mixins/asset_drop.py index 4306b48..96a1b6d 100644 --- a/pyPhotoAlbum/mixins/asset_drop.py +++ b/pyPhotoAlbum/mixins/asset_drop.py @@ -103,21 +103,29 @@ class AssetDropMixin: def _handle_drop_on_empty_space(self, image_path, x, y): """Handle dropping an image onto empty space""" + import os main_window = self.window() if not (hasattr(main_window, 'project') and main_window.project and main_window.project.pages): return - img_width, img_height = self._calculate_image_dimensions(image_path) target_page, page_index, page_renderer = self._get_page_at(x, y) if not (target_page and page_renderer): print("Drop location not on any page") return - self._add_new_image_to_page( - image_path, target_page, page_index, page_renderer, - x, y, img_width, img_height, main_window - ) + try: + # Import asset first, then calculate dimensions from imported asset + asset_path = main_window.project.asset_manager.import_asset(image_path) + full_asset_path = os.path.join(main_window.project.folder_path, asset_path) + img_width, img_height = self._calculate_image_dimensions(full_asset_path) + + self._add_new_image_to_page( + asset_path, target_page, page_index, page_renderer, + x, y, img_width, img_height, main_window + ) + except Exception as e: + print(f"Error importing dropped image: {e}") def _calculate_image_dimensions(self, image_path): """Calculate scaled image dimensions for new image""" @@ -137,32 +145,27 @@ class AssetDropMixin: print(f"Error loading image dimensions: {e}") return 200, 150 - def _add_new_image_to_page(self, image_path, target_page, page_index, + def _add_new_image_to_page(self, asset_path, target_page, page_index, page_renderer, x, y, img_width, img_height, main_window): - """Add a new image element to the target page""" + """Add a new image element to the target page (asset already imported)""" if page_index >= 0: self.current_page_index = page_index page_local_x, page_local_y = page_renderer.screen_to_page(x, y) - try: - asset_path = main_window.project.asset_manager.import_asset(image_path) + new_image = ImageData( + image_path=asset_path, + x=page_local_x, + y=page_local_y, + width=img_width, + height=img_height + ) - new_image = ImageData( - image_path=asset_path, - x=page_local_x, - y=page_local_y, - width=img_width, - height=img_height - ) + cmd = AddElementCommand( + target_page.layout, + new_image, + asset_manager=main_window.project.asset_manager + ) + main_window.project.history.execute(cmd) - cmd = AddElementCommand( - target_page.layout, - new_image, - asset_manager=main_window.project.asset_manager - ) - main_window.project.history.execute(cmd) - - print(f"Added new image to page {page_index + 1} at ({page_local_x:.1f}, {page_local_y:.1f}): {asset_path}") - except Exception as e: - print(f"Error adding dropped image: {e}") + print(f"Added new image to page {page_index + 1} at ({page_local_x:.1f}, {page_local_y:.1f}): {asset_path}") diff --git a/pyPhotoAlbum/mixins/async_loading.py b/pyPhotoAlbum/mixins/async_loading.py index 39c52cb..fb4da81 100644 --- a/pyPhotoAlbum/mixins/async_loading.py +++ b/pyPhotoAlbum/mixins/async_loading.py @@ -168,25 +168,32 @@ class AsyncLoadingMixin: if not image_data.image_path: return - # Resolve path + # Resolve path - only load from project's assets folder + # Search paths are only used for healing, not for async loading from pyPhotoAlbum.models import get_asset_search_paths import os - image_full_path = image_data.image_path - if not os.path.isabs(image_data.image_path): - project_folder, search_paths = get_asset_search_paths() - possible_paths = [] + # Only load images that are properly in the assets folder + # Paths must be relative and start with "assets/" + if os.path.isabs(image_data.image_path): + logger.warning(f"Skipping absolute path (needs healing): {image_data.image_path}") + return - if project_folder: - possible_paths.append(os.path.join(project_folder, image_data.image_path)) + if not image_data.image_path.startswith("assets/"): + logger.warning(f"Skipping path not in assets folder (needs healing): {image_data.image_path}") + return - for search_path in search_paths: - possible_paths.append(os.path.join(search_path, image_data.image_path)) + project_folder, _ = get_asset_search_paths() + if not project_folder: + logger.warning("No project folder set, cannot load image") + return - for path in possible_paths: - if os.path.exists(path): - image_full_path = path - break + full_path = os.path.join(project_folder, image_data.image_path) + if not os.path.exists(full_path): + logger.warning(f"Image not found in assets (needs healing): {image_data.image_path}") + return + + image_full_path = full_path # Calculate target size (max 2048px like original) target_size = (2048, 2048) # Will be downsampled if larger diff --git a/pyPhotoAlbum/mixins/operations/element_ops.py b/pyPhotoAlbum/mixins/operations/element_ops.py index e75fb2a..c7cc499 100644 --- a/pyPhotoAlbum/mixins/operations/element_ops.py +++ b/pyPhotoAlbum/mixins/operations/element_ops.py @@ -39,11 +39,13 @@ class ElementOperationsMixin: return try: + import os # Import asset to project asset_path = self.project.asset_manager.import_asset(file_path) - - # Load image to get dimensions - img = Image.open(file_path) + + # Load image from imported asset (not from original source) + full_asset_path = os.path.join(self.project.folder_path, asset_path) + img = Image.open(full_asset_path) img_width, img_height = img.size # Scale to reasonable size (max 300px) diff --git a/pyPhotoAlbum/mixins/operations/file_ops.py b/pyPhotoAlbum/mixins/operations/file_ops.py index 3052bd8..51b1cbc 100644 --- a/pyPhotoAlbum/mixins/operations/file_ops.py +++ b/pyPhotoAlbum/mixins/operations/file_ops.py @@ -240,7 +240,13 @@ class FileOperationsMixin: # Update view (this will trigger progressive image loading) self.update_view() - self.show_status(f"Project opened: {project.name}") + # Check for missing assets and inform user + missing_assets = self._check_missing_assets() + if missing_assets: + self._show_missing_assets_warning(missing_assets) + self.show_status(f"Project opened: {project.name} ({len(missing_assets)} missing images)") + else: + self.show_status(f"Project opened: {project.name}") print(f"Successfully loaded project: {project.name}") def _on_load_failed(self, error_msg: str): @@ -304,6 +310,55 @@ class FileOperationsMixin: # Update the view to reflect any changes self.update_view() + def _check_missing_assets(self) -> list: + """Check for missing assets in the project - returns list of missing paths""" + import os + from pyPhotoAlbum.models import ImageData + + missing = [] + for page in self.project.pages: + for element in page.layout.elements: + if isinstance(element, ImageData) and element.image_path: + # Absolute paths need healing + if os.path.isabs(element.image_path): + missing.append(element.image_path) + # Paths not in assets/ need healing + elif not element.image_path.startswith("assets/"): + missing.append(element.image_path) + else: + # Check if file exists in assets + full_path = os.path.join(self.project.folder_path, element.image_path) + if not os.path.exists(full_path): + missing.append(element.image_path) + return list(set(missing)) # Remove duplicates + + def _show_missing_assets_warning(self, missing_assets: list): + """Show a warning about missing assets""" + from PyQt6.QtWidgets import QMessageBox + + # Build message with list of missing images + if len(missing_assets) <= 5: + asset_list = "\n".join(f" • {path}" for path in missing_assets) + else: + asset_list = "\n".join(f" • {path}" for path in missing_assets[:5]) + asset_list += f"\n ... and {len(missing_assets) - 5} more" + + msg = QMessageBox(self) + msg.setIcon(QMessageBox.Icon.Warning) + msg.setWindowTitle("Missing Assets") + msg.setText(f"{len(missing_assets)} image(s) could not be found in the assets folder:") + msg.setInformativeText(asset_list) + msg.setDetailedText("These images need to be reconnected using the 'Heal Assets' feature.\n\n" + "Go to: Home → Heal Assets\n\n" + "Add search paths where the original images might be located, " + "then click 'Attempt Healing' to find and import them.") + msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Open) + msg.button(QMessageBox.StandardButton.Open).setText("Open Heal Assets") + + result = msg.exec() + if result == QMessageBox.StandardButton.Open: + self.heal_assets() + @ribbon_action( label="Project Settings", tooltip="Configure project-wide page size and defaults", diff --git a/pyPhotoAlbum/models.py b/pyPhotoAlbum/models.py index b26e461..5d57491 100644 --- a/pyPhotoAlbum/models.py +++ b/pyPhotoAlbum/models.py @@ -181,34 +181,18 @@ class ImageData(BaseLayoutElement): # Handle both absolute and relative paths image_full_path = self.image_path if self.image_path and not os.path.isabs(self.image_path): - # Relative path - use resolution context - project_folder, search_paths = get_asset_search_paths() + # Relative path - only look in project folder (assets) + # Search paths are only used for healing, not for rendering + project_folder, _ = get_asset_search_paths() - possible_paths = [] - - # Try project folder first if available if project_folder: - possible_paths.append(os.path.join(project_folder, self.image_path)) - - # Try additional search paths - for search_path in search_paths: - possible_paths.append(os.path.join(search_path, self.image_path)) - - # Fallback to old behavior for compatibility - possible_paths.extend([ - self.image_path, # Try as-is - os.path.join(os.getcwd(), self.image_path), # Relative to CWD - os.path.join(os.path.dirname(os.getcwd()), self.image_path), # Parent of CWD - ]) - - for path in possible_paths: - if os.path.exists(path): - image_full_path = path - # print(f"ImageData: Resolved {self.image_path} → {path}") - break - else: - print(f"ImageData: Could not resolve path: {self.image_path}") - print(f" Tried paths: {possible_paths[:3]}") # Print first 3 to avoid clutter + full_path = os.path.join(project_folder, self.image_path) + if os.path.exists(full_path): + image_full_path = full_path + else: + print(f"ImageData: Could not resolve path: {self.image_path}") + print(f" Expected at: {full_path}") + print(f" Image needs healing - not found in assets folder") # NOTE: Async loading is now handled by page_layout.py calling request_image_load() # This sync path should only be reached if async loading is not available diff --git a/pyPhotoAlbum/pdf_exporter.py b/pyPhotoAlbum/pdf_exporter.py index b4efcdf..87e1831 100644 --- a/pyPhotoAlbum/pdf_exporter.py +++ b/pyPhotoAlbum/pdf_exporter.py @@ -8,6 +8,9 @@ from dataclasses import dataclass from reportlab.lib.pagesizes import A4 from reportlab.pdfgen import canvas from reportlab.lib.utils import ImageReader +from reportlab.platypus import Paragraph +from reportlab.lib.styles import ParagraphStyle +from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT from PIL import Image import math from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData @@ -424,31 +427,16 @@ class PDFExporter: if os.path.isabs(image_path) and os.path.exists(image_path): return image_path - # For relative paths, try resolution using the same logic as ImageData + # For relative paths, only look in project folder (assets) + # Search paths are only used for healing, not for PDF export from pyPhotoAlbum.models import get_asset_search_paths - project_folder, search_paths = get_asset_search_paths() - possible_paths = [] + project_folder, _ = get_asset_search_paths() - # Try project folder first if available if project_folder: - possible_paths.append(os.path.join(project_folder, image_path)) - - # Try additional search paths - for search_path in search_paths: - possible_paths.append(os.path.join(search_path, image_path)) - - # Fallback paths for compatibility - possible_paths.extend([ - image_path, # Try as-is - os.path.join(os.getcwd(), image_path), # Relative to CWD - os.path.join(os.path.dirname(os.getcwd()), image_path), # Parent of CWD - ]) - - # Find first existing path - for path in possible_paths: - if os.path.exists(path): - return path + full_path = os.path.join(project_folder, image_path) + if os.path.exists(full_path): + return full_path return None @@ -583,11 +571,12 @@ class PDFExporter: print(f"WARNING: {warning}") self.warnings.append(warning) - def _render_textbox(self, c: canvas.Canvas, text_element: 'TextBoxData', + def _render_textbox(self, c: canvas.Canvas, text_element: 'TextBoxData', x_pt: float, y_pt: float, width_pt: float, height_pt: float): """ Render a text box element on the PDF canvas with transparent background. - + Text is word-wrapped to fit within the box boundaries. + Args: c: ReportLab canvas text_element: TextBoxData instance @@ -595,12 +584,18 @@ class PDFExporter: """ if not text_element.text_content: return - + # Get font settings font_family = text_element.font_settings.get('family', 'Helvetica') - font_size = text_element.font_settings.get('size', 12) + font_size_px = text_element.font_settings.get('size', 12) font_color = text_element.font_settings.get('color', (0, 0, 0)) - + + # Convert font size from pixels to PDF points (same conversion as element dimensions) + # Font size is stored in pixels at working_dpi, same as element position/size + dpi = self.project.working_dpi + font_size_mm = font_size_px * 25.4 / dpi + font_size = font_size_mm * self.MM_TO_POINTS + # Map common font names to ReportLab standard fonts font_map = { 'Arial': 'Helvetica', @@ -608,23 +603,50 @@ class PDFExporter: 'Courier New': 'Courier', } font_family = font_map.get(font_family, font_family) - + + # Normalize color to hex for Paragraph style + if all(isinstance(x, int) and x > 1 for x in font_color): + color_hex = '#{:02x}{:02x}{:02x}'.format(*font_color) + else: + # Convert 0-1 range to 0-255 then to hex + color_hex = '#{:02x}{:02x}{:02x}'.format( + int(font_color[0] * 255), + int(font_color[1] * 255), + int(font_color[2] * 255) + ) + + # Map alignment to ReportLab constants + alignment_map = { + 'left': TA_LEFT, + 'center': TA_CENTER, + 'right': TA_RIGHT, + } + text_alignment = alignment_map.get(text_element.alignment, TA_LEFT) + + # Create paragraph style with word wrapping + style = ParagraphStyle( + 'textbox', + fontName=font_family, + fontSize=font_size, + leading=font_size * 1.2, # Line spacing (120% of font size) + textColor=color_hex, + alignment=text_alignment, + ) + + # Escape special XML characters and convert newlines to
tags + text_content = text_element.text_content + text_content = text_content.replace('&', '&') + text_content = text_content.replace('<', '<') + text_content = text_content.replace('>', '>') + text_content = text_content.replace('\n', '
') + + # Create paragraph with the text + para = Paragraph(text_content, style) + # Save state for transformations c.saveState() - + try: - # Set font - c.setFont(font_family, font_size) - - # Set color to black (normalize from 0-255 to 0-1 if needed) - if all(isinstance(x, int) and x > 1 for x in font_color): - color = tuple(x / 255.0 for x in font_color) - else: - color = font_color - c.setFillColorRGB(*color) - - # No background is drawn - transparent background in PDF - # Apply rotation if needed if text_element.rotation != 0: # Move to element center @@ -632,36 +654,32 @@ class PDFExporter: center_y = y_pt + height_pt / 2 c.translate(center_x, center_y) c.rotate(text_element.rotation) - # Draw text relative to rotation center - text_y = -height_pt / 2 + font_size # Adjust for text baseline - - if text_element.alignment == 'center': - text_x = -c.stringWidth(text_element.text_content, font_family, font_size) / 2 - elif text_element.alignment == 'right': - text_x = width_pt / 2 - c.stringWidth(text_element.text_content, font_family, font_size) - else: # left - text_x = -width_pt / 2 - - c.drawString(text_x, text_y, text_element.text_content) + + # Wrap and draw paragraph relative to center + # wrapOn calculates the actual height needed + para_width, para_height = para.wrapOn(c, width_pt, height_pt) + + # Position at top-left of box (relative to center after rotation) + draw_x = -width_pt / 2 + draw_y = height_pt / 2 - para_height + + para.drawOn(c, draw_x, draw_y) else: - # No rotation - draw normally with alignment - text_y = y_pt + font_size # Adjust for text baseline - - if text_element.alignment == 'center': - text_x = x_pt + (width_pt - c.stringWidth(text_element.text_content, - font_family, font_size)) / 2 - elif text_element.alignment == 'right': - text_x = x_pt + width_pt - c.stringWidth(text_element.text_content, - font_family, font_size) - else: # left - text_x = x_pt - - c.drawString(text_x, text_y, text_element.text_content) - + # No rotation - draw normally + # wrapOn calculates the actual height needed for the wrapped text + para_width, para_height = para.wrapOn(c, width_pt, height_pt) + + # drawOn draws from bottom-left of the paragraph + # We want text at top of box, so: draw_y = box_top - para_height + draw_x = x_pt + draw_y = y_pt + height_pt - para_height + + para.drawOn(c, draw_x, draw_y) + except Exception as e: warning = f"Error rendering text box: {str(e)}" print(f"WARNING: {warning}") self.warnings.append(warning) - + finally: c.restoreState() diff --git a/pyPhotoAlbum/project_serializer.py b/pyPhotoAlbum/project_serializer.py index 2d33735..ef9873e 100644 --- a/pyPhotoAlbum/project_serializer.py +++ b/pyPhotoAlbum/project_serializer.py @@ -252,13 +252,11 @@ def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Project: _normalize_asset_paths(project, extract_to) # Set asset resolution context for ImageData rendering - # Include the directory containing the .ppz file as a search path + # Only set project folder - search paths are reserved for healing functionality from pyPhotoAlbum.models import set_asset_resolution_context - zip_directory = os.path.dirname(os.path.abspath(zip_path)) - set_asset_resolution_context(extract_to, additional_search_paths=[zip_directory]) + set_asset_resolution_context(extract_to) print(f"Project loaded from {zip_path} to {extract_to}") - print(f"Additional search path: {zip_directory}") return project diff --git a/tests/test_pdf_export.py b/tests/test_pdf_export.py index bc667d6..cd729bb 100755 --- a/tests/test_pdf_export.py +++ b/tests/test_pdf_export.py @@ -74,10 +74,10 @@ def test_pdf_exporter_with_text(): """Test PDF export with text boxes""" project = Project("Test Text Project") project.page_size_mm = (210, 297) - + # Create page with text box page = Page(page_number=1, is_double_spread=False) - + # Add a text box text_box = TextBoxData( text_content="Hello, World!", @@ -86,24 +86,261 @@ def test_pdf_exporter_with_text(): x=50, y=50, width=100, height=30 ) page.layout.add_element(text_box) - + project.add_page(page) - + # Export to temporary file with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp: tmp_path = tmp.name - + try: exporter = PDFExporter(project) success, warnings = exporter.export(tmp_path) - + assert success, f"Export failed: {warnings}" assert os.path.exists(tmp_path), "PDF file was not created" - + print(f"✓ Text box PDF export successful: {tmp_path}") if warnings: print(f" Warnings: {warnings}") - + + finally: + if os.path.exists(tmp_path): + os.remove(tmp_path) + + +def test_pdf_text_position_and_size(): + """ + Test that text in PDF is correctly positioned and sized relative to its text box. + + This test verifies: + 1. Font size is properly scaled (not used directly as PDF points) + 2. Text is positioned inside the text box (not above it) + 3. Text respects the top-alignment used in the UI + """ + import pdfplumber + + project = Project("Test Text Position") + project.page_size_mm = (210, 297) # A4 + project.working_dpi = 96 + + # Create page with text box + page = Page(page_number=1, is_double_spread=False) + + # Create a text box with specific dimensions in pixels (at 96 DPI) + # Text box: 200px wide x 100px tall, positioned at (100, 100) + # Font size: 48 pixels (stored in same units as element size) + text_box_x_px = 100 + text_box_y_px = 100 + text_box_width_px = 200 + text_box_height_px = 100 + font_size_px = 48 # Font size in same pixel units as element + + text_box = TextBoxData( + text_content="Test", + font_settings={"family": "Helvetica", "size": font_size_px, "color": (0, 0, 0)}, + alignment="left", + x=text_box_x_px, + y=text_box_y_px, + width=text_box_width_px, + height=text_box_height_px + ) + page.layout.add_element(text_box) + project.add_page(page) + + # Calculate expected PDF values + MM_TO_POINTS = 2.834645669 + dpi = project.working_dpi + page_height_pt = 297 * MM_TO_POINTS # ~842 points + + # Convert text box dimensions to points + text_box_x_mm = text_box_x_px * 25.4 / dpi + text_box_y_mm = text_box_y_px * 25.4 / dpi + text_box_width_mm = text_box_width_px * 25.4 / dpi + text_box_height_mm = text_box_height_px * 25.4 / dpi + + text_box_x_pt = text_box_x_mm * MM_TO_POINTS + text_box_y_pt_bottom = page_height_pt - (text_box_y_mm * MM_TO_POINTS) - (text_box_height_mm * MM_TO_POINTS) + text_box_y_pt_top = text_box_y_pt_bottom + (text_box_height_mm * MM_TO_POINTS) + text_box_height_pt = text_box_height_mm * MM_TO_POINTS + + # Font size should also be converted from pixels to points + expected_font_size_pt = font_size_px * 25.4 / dpi * MM_TO_POINTS + + # Export to temporary file + with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp: + tmp_path = tmp.name + + try: + exporter = PDFExporter(project) + success, warnings = exporter.export(tmp_path) + + assert success, f"Export failed: {warnings}" + + # Extract text position from PDF + with pdfplumber.open(tmp_path) as pdf: + page_pdf = pdf.pages[0] + chars = page_pdf.chars + + assert len(chars) > 0, "No text found in PDF" + + # Get the first character's position and font size + first_char = chars[0] + text_x = first_char['x0'] + text_y_baseline = first_char['y0'] # This is the baseline y position + actual_font_size = first_char['size'] + + print(f"\nText Position Analysis:") + print(f" Text box (in pixels at {dpi} DPI): x={text_box_x_px}, y={text_box_y_px}, " + f"w={text_box_width_px}, h={text_box_height_px}") + print(f" Text box (in PDF points): x={text_box_x_pt:.1f}, " + f"y_bottom={text_box_y_pt_bottom:.1f}, y_top={text_box_y_pt_top:.1f}, " + f"height={text_box_height_pt:.1f}") + print(f" Font size (pixels): {font_size_px}") + print(f" Expected font size (points): {expected_font_size_pt:.1f}") + print(f" Actual font size (points): {actual_font_size:.1f}") + print(f" Actual text x: {text_x:.1f}") + print(f" Actual text y (baseline): {text_y_baseline:.1f}") + + # Verify font size is scaled correctly (tolerance of 1pt) + font_size_diff = abs(actual_font_size - expected_font_size_pt) + assert font_size_diff < 2.0, ( + f"Font size mismatch: expected ~{expected_font_size_pt:.1f}pt, " + f"got {actual_font_size:.1f}pt (diff: {font_size_diff:.1f}pt). " + f"Font size should be converted from pixels to points." + ) + + # Verify text X position is near the left edge of the text box + x_diff = abs(text_x - text_box_x_pt) + assert x_diff < 5.0, ( + f"Text X position mismatch: expected ~{text_box_x_pt:.1f}, " + f"got {text_x:.1f} (diff: {x_diff:.1f}pt)" + ) + + # Verify text Y baseline is INSIDE the text box (not above it) + # For top-aligned text, baseline should be within the box bounds + # pdfplumber y-coordinates use PDF coordinate system: origin at bottom-left, y increases upward + # So y0 is already the y-coordinate from the bottom of the page + text_y_from_bottom = text_y_baseline + + # Text baseline should be between box bottom and box top + # Allow some margin for ascender/descender + margin = actual_font_size * 0.3 # 30% margin for font metrics + + assert text_y_from_bottom >= text_box_y_pt_bottom - margin, ( + f"Text is below the text box! " + f"Text baseline y={text_y_from_bottom:.1f}, box bottom={text_box_y_pt_bottom:.1f}" + ) + assert text_y_from_bottom <= text_box_y_pt_top + margin, ( + f"Text baseline is above the text box! " + f"Text baseline y={text_y_from_bottom:.1f}, box top={text_box_y_pt_top:.1f}. " + f"Text should be positioned inside the box, not above it." + ) + + print(f" Text y (from bottom): {text_y_from_bottom:.1f}") + print(f" Text is inside box bounds: ✓") + print(f"\n✓ Text position and size test passed!") + + finally: + if os.path.exists(tmp_path): + os.remove(tmp_path) + + +def test_pdf_text_wrapping(): + """ + Test that text wraps correctly within the text box boundaries. + + This test verifies: + 1. Long text is word-wrapped to fit within the box width + 2. Multiple lines are rendered correctly + 3. Text stays within the box boundaries + """ + import pdfplumber + + project = Project("Test Text Wrapping") + project.page_size_mm = (210, 297) # A4 + project.working_dpi = 96 + + # Create page with text box + page = Page(page_number=1, is_double_spread=False) + + # Create a text box with long text that should wrap + text_box_x_px = 100 + text_box_y_px = 100 + text_box_width_px = 200 # Narrow box to force wrapping + text_box_height_px = 200 # Tall enough for multiple lines + font_size_px = 24 + + long_text = "This is a long piece of text that should wrap to multiple lines within the text box boundaries." + + text_box = TextBoxData( + text_content=long_text, + font_settings={"family": "Helvetica", "size": font_size_px, "color": (0, 0, 0)}, + alignment="left", + x=text_box_x_px, + y=text_box_y_px, + width=text_box_width_px, + height=text_box_height_px + ) + page.layout.add_element(text_box) + project.add_page(page) + + # Calculate box boundaries in PDF points + MM_TO_POINTS = 2.834645669 + dpi = project.working_dpi + + text_box_x_mm = text_box_x_px * 25.4 / dpi + text_box_width_mm = text_box_width_px * 25.4 / dpi + text_box_x_pt = text_box_x_mm * MM_TO_POINTS + text_box_width_pt = text_box_width_mm * MM_TO_POINTS + text_box_right_pt = text_box_x_pt + text_box_width_pt + + # Export to temporary file + with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp: + tmp_path = tmp.name + + try: + exporter = PDFExporter(project) + success, warnings = exporter.export(tmp_path) + + assert success, f"Export failed: {warnings}" + + # Extract text from PDF + with pdfplumber.open(tmp_path) as pdf: + page_pdf = pdf.pages[0] + chars = page_pdf.chars + + assert len(chars) > 0, "No text found in PDF" + + # Get all unique Y positions (lines) + y_positions = sorted(set(round(c['top'], 1) for c in chars)) + + print(f"\nText Wrapping Analysis:") + print(f" Text box width: {text_box_width_pt:.1f}pt") + print(f" Text box x: {text_box_x_pt:.1f}pt to {text_box_right_pt:.1f}pt") + print(f" Number of lines: {len(y_positions)}") + print(f" Line Y positions: {y_positions[:5]}...") # Show first 5 + + # Verify text wrapped to multiple lines + assert len(y_positions) > 1, ( + f"Text should wrap to multiple lines but only found {len(y_positions)} line(s)" + ) + + # Verify all characters are within box width (with small tolerance) + tolerance = 5.0 # Small tolerance for rounding + for char in chars: + char_x = char['x0'] + char_right = char['x1'] + assert char_x >= text_box_x_pt - tolerance, ( + f"Character '{char['text']}' at x={char_x:.1f} is left of box start {text_box_x_pt:.1f}" + ) + assert char_right <= text_box_right_pt + tolerance, ( + f"Character '{char['text']}' ends at x={char_right:.1f} which exceeds box right {text_box_right_pt:.1f}" + ) + + print(f" All characters within box width: ✓") + print(f"\n✓ Text wrapping test passed!") + finally: if os.path.exists(tmp_path): os.remove(tmp_path) @@ -113,7 +350,7 @@ def test_pdf_exporter_facing_pages_alignment(): """Test that double spreads align to facing pages""" project = Project("Test Facing Pages") project.page_size_mm = (210, 297) - + # Add single page (page 1) page1 = Page(page_number=1, is_double_spread=False) project.add_page(page1) @@ -761,6 +998,8 @@ if __name__ == "__main__": test_pdf_exporter_basic() test_pdf_exporter_double_spread() test_pdf_exporter_with_text() + test_pdf_text_position_and_size() + test_pdf_text_wrapping() test_pdf_exporter_facing_pages_alignment() test_pdf_exporter_missing_image() test_pdf_exporter_spanning_image()