fixed bug in pdf export
Some checks failed
Python CI / test (push) Has been cancelled
Lint / lint (push) Has been cancelled
Tests / test (3.10) (push) Has been cancelled
Tests / test (3.11) (push) Has been cancelled
Tests / test (3.9) (push) Has been cancelled

fixed bug in asset loading
This commit is contained in:
Duncan Tourolle 2025-11-26 22:59:42 +01:00
parent 56ea7d5ea9
commit 73c504abb7
10 changed files with 510 additions and 180 deletions

View File

@ -78,24 +78,38 @@ class AssetHealDialog(QDialog):
self.setLayout(layout) self.setLayout(layout)
def _scan_missing_assets(self): 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 from pyPhotoAlbum.models import ImageData
self.missing_assets.clear() self.missing_assets.clear()
self.missing_list.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 page in self.project.pages:
for element in page.layout.elements: for element in page.layout.elements:
if isinstance(element, ImageData) and element.image_path: if isinstance(element, ImageData) and element.image_path:
# Check if path exists needs_healing = False
if os.path.isabs(element.image_path): reason = ""
full_path = element.image_path
else:
full_path = os.path.join(self.project.folder_path, element.image_path)
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) self.missing_assets.add(element.image_path)
print(f"Asset needs healing: {element.image_path} ({reason})")
# Display missing assets # Display missing assets
if self.missing_assets: if self.missing_assets:
@ -128,22 +142,14 @@ class AssetHealDialog(QDialog):
self.search_list.takeItem(current_row) self.search_list.takeItem(current_row)
def _attempt_healing(self): 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 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 healed_count = 0
imported_count = 0 imported_count = 0
still_missing = [] 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) set_asset_resolution_context(self.project.folder_path, self.search_paths)
# Build mapping of missing paths to elements # Build mapping of missing paths to elements
@ -161,19 +167,34 @@ class AssetHealDialog(QDialog):
found_path = None found_path = None
filename = os.path.basename(asset_path) filename = os.path.basename(asset_path)
# Search in each search path # FIRST: Try to resolve the stored path directly from project folder
for search_path in self.search_paths: # This handles paths like "../../home/user/Photos/image.jpg"
# Try direct match if not os.path.isabs(asset_path):
candidate = os.path.join(search_path, filename) resolved = os.path.normpath(os.path.join(self.project.folder_path, asset_path))
if os.path.exists(candidate): if os.path.exists(resolved):
found_path = candidate found_path = resolved
break print(f"Resolved relative path: {asset_path}{resolved}")
# Try with same relative path # SECOND: If it's an absolute path, check if it exists directly
candidate = os.path.join(search_path, asset_path) if not found_path and os.path.isabs(asset_path):
if os.path.exists(candidate): if os.path.exists(asset_path):
found_path = candidate found_path = asset_path
break 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: if found_path:
healed_count += 1 healed_count += 1
@ -223,5 +244,8 @@ class AssetHealDialog(QDialog):
QMessageBox.information(self, "Healing Results", message) 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 # Rescan to update the list
self._scan_missing_assets() self._scan_missing_assets()

View File

@ -169,8 +169,8 @@ class AsyncProjectLoader(QThread):
self.progress_updated.emit(95, 100, "Setting up asset resolution...") self.progress_updated.emit(95, 100, "Setting up asset resolution...")
# Set asset resolution context # Set asset resolution context
zip_directory = os.path.dirname(os.path.abspath(self.zip_path)) # Only set project folder - search paths are reserved for healing functionality
set_asset_resolution_context(extract_to, additional_search_paths=[zip_directory]) set_asset_resolution_context(extract_to)
if self._cancelled: if self._cancelled:
return return

View File

@ -103,21 +103,29 @@ class AssetDropMixin:
def _handle_drop_on_empty_space(self, image_path, x, y): def _handle_drop_on_empty_space(self, image_path, x, y):
"""Handle dropping an image onto empty space""" """Handle dropping an image onto empty space"""
import os
main_window = self.window() main_window = self.window()
if not (hasattr(main_window, 'project') and main_window.project and main_window.project.pages): if not (hasattr(main_window, 'project') and main_window.project and main_window.project.pages):
return return
img_width, img_height = self._calculate_image_dimensions(image_path)
target_page, page_index, page_renderer = self._get_page_at(x, y) target_page, page_index, page_renderer = self._get_page_at(x, y)
if not (target_page and page_renderer): if not (target_page and page_renderer):
print("Drop location not on any page") print("Drop location not on any page")
return return
self._add_new_image_to_page( try:
image_path, target_page, page_index, page_renderer, # Import asset first, then calculate dimensions from imported asset
x, y, img_width, img_height, main_window 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): def _calculate_image_dimensions(self, image_path):
"""Calculate scaled image dimensions for new image""" """Calculate scaled image dimensions for new image"""
@ -137,32 +145,27 @@ class AssetDropMixin:
print(f"Error loading image dimensions: {e}") print(f"Error loading image dimensions: {e}")
return 200, 150 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): 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: if page_index >= 0:
self.current_page_index = page_index self.current_page_index = page_index
page_local_x, page_local_y = page_renderer.screen_to_page(x, y) page_local_x, page_local_y = page_renderer.screen_to_page(x, y)
try: new_image = ImageData(
asset_path = main_window.project.asset_manager.import_asset(image_path) image_path=asset_path,
x=page_local_x,
y=page_local_y,
width=img_width,
height=img_height
)
new_image = ImageData( cmd = AddElementCommand(
image_path=asset_path, target_page.layout,
x=page_local_x, new_image,
y=page_local_y, asset_manager=main_window.project.asset_manager
width=img_width, )
height=img_height main_window.project.history.execute(cmd)
)
cmd = AddElementCommand( print(f"Added new image to page {page_index + 1} at ({page_local_x:.1f}, {page_local_y:.1f}): {asset_path}")
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}")

View File

@ -168,25 +168,32 @@ class AsyncLoadingMixin:
if not image_data.image_path: if not image_data.image_path:
return 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 from pyPhotoAlbum.models import get_asset_search_paths
import os import os
image_full_path = image_data.image_path # Only load images that are properly in the assets folder
if not os.path.isabs(image_data.image_path): # Paths must be relative and start with "assets/"
project_folder, search_paths = get_asset_search_paths() if os.path.isabs(image_data.image_path):
possible_paths = [] logger.warning(f"Skipping absolute path (needs healing): {image_data.image_path}")
return
if project_folder: if not image_data.image_path.startswith("assets/"):
possible_paths.append(os.path.join(project_folder, image_data.image_path)) logger.warning(f"Skipping path not in assets folder (needs healing): {image_data.image_path}")
return
for search_path in search_paths: project_folder, _ = get_asset_search_paths()
possible_paths.append(os.path.join(search_path, image_data.image_path)) if not project_folder:
logger.warning("No project folder set, cannot load image")
return
for path in possible_paths: full_path = os.path.join(project_folder, image_data.image_path)
if os.path.exists(path): if not os.path.exists(full_path):
image_full_path = path logger.warning(f"Image not found in assets (needs healing): {image_data.image_path}")
break return
image_full_path = full_path
# Calculate target size (max 2048px like original) # Calculate target size (max 2048px like original)
target_size = (2048, 2048) # Will be downsampled if larger target_size = (2048, 2048) # Will be downsampled if larger

View File

@ -39,11 +39,13 @@ class ElementOperationsMixin:
return return
try: try:
import os
# Import asset to project # Import asset to project
asset_path = self.project.asset_manager.import_asset(file_path) asset_path = self.project.asset_manager.import_asset(file_path)
# Load image to get dimensions # Load image from imported asset (not from original source)
img = Image.open(file_path) full_asset_path = os.path.join(self.project.folder_path, asset_path)
img = Image.open(full_asset_path)
img_width, img_height = img.size img_width, img_height = img.size
# Scale to reasonable size (max 300px) # Scale to reasonable size (max 300px)

View File

@ -240,7 +240,13 @@ class FileOperationsMixin:
# Update view (this will trigger progressive image loading) # Update view (this will trigger progressive image loading)
self.update_view() 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}") print(f"Successfully loaded project: {project.name}")
def _on_load_failed(self, error_msg: str): def _on_load_failed(self, error_msg: str):
@ -304,6 +310,55 @@ class FileOperationsMixin:
# Update the view to reflect any changes # Update the view to reflect any changes
self.update_view() 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( @ribbon_action(
label="Project Settings", label="Project Settings",
tooltip="Configure project-wide page size and defaults", tooltip="Configure project-wide page size and defaults",

View File

@ -181,34 +181,18 @@ class ImageData(BaseLayoutElement):
# Handle both absolute and relative paths # Handle both absolute and relative paths
image_full_path = self.image_path image_full_path = self.image_path
if self.image_path and not os.path.isabs(self.image_path): if self.image_path and not os.path.isabs(self.image_path):
# Relative path - use resolution context # Relative path - only look in project folder (assets)
project_folder, search_paths = get_asset_search_paths() # 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: if project_folder:
possible_paths.append(os.path.join(project_folder, self.image_path)) full_path = os.path.join(project_folder, self.image_path)
if os.path.exists(full_path):
# Try additional search paths image_full_path = full_path
for search_path in search_paths: else:
possible_paths.append(os.path.join(search_path, self.image_path)) print(f"ImageData: Could not resolve path: {self.image_path}")
print(f" Expected at: {full_path}")
# Fallback to old behavior for compatibility print(f" Image needs healing - not found in assets folder")
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
# NOTE: Async loading is now handled by page_layout.py calling request_image_load() # 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 # This sync path should only be reached if async loading is not available

View File

@ -8,6 +8,9 @@ from dataclasses import dataclass
from reportlab.lib.pagesizes import A4 from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas from reportlab.pdfgen import canvas
from reportlab.lib.utils import ImageReader 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 from PIL import Image
import math import math
from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData 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): if os.path.isabs(image_path) and os.path.exists(image_path):
return 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 from pyPhotoAlbum.models import get_asset_search_paths
project_folder, search_paths = get_asset_search_paths() project_folder, _ = get_asset_search_paths()
possible_paths = []
# Try project folder first if available
if project_folder: if project_folder:
possible_paths.append(os.path.join(project_folder, image_path)) full_path = os.path.join(project_folder, image_path)
if os.path.exists(full_path):
# Try additional search paths return full_path
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
return None return None
@ -583,11 +571,12 @@ class PDFExporter:
print(f"WARNING: {warning}") print(f"WARNING: {warning}")
self.warnings.append(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): x_pt: float, y_pt: float, width_pt: float, height_pt: float):
""" """
Render a text box element on the PDF canvas with transparent background. Render a text box element on the PDF canvas with transparent background.
Text is word-wrapped to fit within the box boundaries.
Args: Args:
c: ReportLab canvas c: ReportLab canvas
text_element: TextBoxData instance text_element: TextBoxData instance
@ -595,12 +584,18 @@ class PDFExporter:
""" """
if not text_element.text_content: if not text_element.text_content:
return return
# Get font settings # Get font settings
font_family = text_element.font_settings.get('family', 'Helvetica') 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)) 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 # Map common font names to ReportLab standard fonts
font_map = { font_map = {
'Arial': 'Helvetica', 'Arial': 'Helvetica',
@ -608,23 +603,50 @@ class PDFExporter:
'Courier New': 'Courier', 'Courier New': 'Courier',
} }
font_family = font_map.get(font_family, font_family) 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 <br/> tags
text_content = text_element.text_content
text_content = text_content.replace('&', '&amp;')
text_content = text_content.replace('<', '&lt;')
text_content = text_content.replace('>', '&gt;')
text_content = text_content.replace('\n', '<br/>')
# Create paragraph with the text
para = Paragraph(text_content, style)
# Save state for transformations # Save state for transformations
c.saveState() c.saveState()
try: 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 # Apply rotation if needed
if text_element.rotation != 0: if text_element.rotation != 0:
# Move to element center # Move to element center
@ -632,36 +654,32 @@ class PDFExporter:
center_y = y_pt + height_pt / 2 center_y = y_pt + height_pt / 2
c.translate(center_x, center_y) c.translate(center_x, center_y)
c.rotate(text_element.rotation) c.rotate(text_element.rotation)
# Draw text relative to rotation center
text_y = -height_pt / 2 + font_size # Adjust for text baseline # Wrap and draw paragraph relative to center
# wrapOn calculates the actual height needed
if text_element.alignment == 'center': para_width, para_height = para.wrapOn(c, width_pt, height_pt)
text_x = -c.stringWidth(text_element.text_content, font_family, font_size) / 2
elif text_element.alignment == 'right': # Position at top-left of box (relative to center after rotation)
text_x = width_pt / 2 - c.stringWidth(text_element.text_content, font_family, font_size) draw_x = -width_pt / 2
else: # left draw_y = height_pt / 2 - para_height
text_x = -width_pt / 2
para.drawOn(c, draw_x, draw_y)
c.drawString(text_x, text_y, text_element.text_content)
else: else:
# No rotation - draw normally with alignment # No rotation - draw normally
text_y = y_pt + font_size # Adjust for text baseline # wrapOn calculates the actual height needed for the wrapped text
para_width, para_height = para.wrapOn(c, width_pt, height_pt)
if text_element.alignment == 'center':
text_x = x_pt + (width_pt - c.stringWidth(text_element.text_content, # drawOn draws from bottom-left of the paragraph
font_family, font_size)) / 2 # We want text at top of box, so: draw_y = box_top - para_height
elif text_element.alignment == 'right': draw_x = x_pt
text_x = x_pt + width_pt - c.stringWidth(text_element.text_content, draw_y = y_pt + height_pt - para_height
font_family, font_size)
else: # left para.drawOn(c, draw_x, draw_y)
text_x = x_pt
c.drawString(text_x, text_y, text_element.text_content)
except Exception as e: except Exception as e:
warning = f"Error rendering text box: {str(e)}" warning = f"Error rendering text box: {str(e)}"
print(f"WARNING: {warning}") print(f"WARNING: {warning}")
self.warnings.append(warning) self.warnings.append(warning)
finally: finally:
c.restoreState() c.restoreState()

View File

@ -252,13 +252,11 @@ def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Project:
_normalize_asset_paths(project, extract_to) _normalize_asset_paths(project, extract_to)
# Set asset resolution context for ImageData rendering # 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 from pyPhotoAlbum.models import set_asset_resolution_context
zip_directory = os.path.dirname(os.path.abspath(zip_path)) set_asset_resolution_context(extract_to)
set_asset_resolution_context(extract_to, additional_search_paths=[zip_directory])
print(f"Project loaded from {zip_path} to {extract_to}") print(f"Project loaded from {zip_path} to {extract_to}")
print(f"Additional search path: {zip_directory}")
return project return project

View File

@ -74,10 +74,10 @@ def test_pdf_exporter_with_text():
"""Test PDF export with text boxes""" """Test PDF export with text boxes"""
project = Project("Test Text Project") project = Project("Test Text Project")
project.page_size_mm = (210, 297) project.page_size_mm = (210, 297)
# Create page with text box # Create page with text box
page = Page(page_number=1, is_double_spread=False) page = Page(page_number=1, is_double_spread=False)
# Add a text box # Add a text box
text_box = TextBoxData( text_box = TextBoxData(
text_content="Hello, World!", text_content="Hello, World!",
@ -86,24 +86,261 @@ def test_pdf_exporter_with_text():
x=50, y=50, width=100, height=30 x=50, y=50, width=100, height=30
) )
page.layout.add_element(text_box) page.layout.add_element(text_box)
project.add_page(page) project.add_page(page)
# Export to temporary file # Export to temporary file
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp: with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp:
tmp_path = tmp.name tmp_path = tmp.name
try: try:
exporter = PDFExporter(project) exporter = PDFExporter(project)
success, warnings = exporter.export(tmp_path) success, warnings = exporter.export(tmp_path)
assert success, f"Export failed: {warnings}" assert success, f"Export failed: {warnings}"
assert os.path.exists(tmp_path), "PDF file was not created" assert os.path.exists(tmp_path), "PDF file was not created"
print(f"✓ Text box PDF export successful: {tmp_path}") print(f"✓ Text box PDF export successful: {tmp_path}")
if warnings: if warnings:
print(f" Warnings: {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: finally:
if os.path.exists(tmp_path): if os.path.exists(tmp_path):
os.remove(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""" """Test that double spreads align to facing pages"""
project = Project("Test Facing Pages") project = Project("Test Facing Pages")
project.page_size_mm = (210, 297) project.page_size_mm = (210, 297)
# Add single page (page 1) # Add single page (page 1)
page1 = Page(page_number=1, is_double_spread=False) page1 = Page(page_number=1, is_double_spread=False)
project.add_page(page1) project.add_page(page1)
@ -761,6 +998,8 @@ if __name__ == "__main__":
test_pdf_exporter_basic() test_pdf_exporter_basic()
test_pdf_exporter_double_spread() test_pdf_exporter_double_spread()
test_pdf_exporter_with_text() test_pdf_exporter_with_text()
test_pdf_text_position_and_size()
test_pdf_text_wrapping()
test_pdf_exporter_facing_pages_alignment() test_pdf_exporter_facing_pages_alignment()
test_pdf_exporter_missing_image() test_pdf_exporter_missing_image()
test_pdf_exporter_spanning_image() test_pdf_exporter_spanning_image()