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()