fixed bug in pdf export
fixed bug in asset loading
This commit is contained in:
parent
56ea7d5ea9
commit
73c504abb7
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 <br/> 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', '<br/>')
|
||||
|
||||
# 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()
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user