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

View File

@ -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

View File

@ -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}")

View File

@ -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

View File

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

View File

@ -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",

View File

@ -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

View File

@ -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
@ -587,6 +575,7 @@ class PDFExporter:
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
@ -598,9 +587,15 @@ class PDFExporter:
# 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',
@ -609,22 +604,49 @@ class PDFExporter:
}
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
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,31 +654,27 @@ 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
# Wrap and draw paragraph relative to center
# wrapOn calculates the actual height needed
para_width, para_height = para.wrapOn(c, width_pt, height_pt)
c.drawString(text_x, text_y, text_element.text_content)
# 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
# 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)
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
# 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
c.drawString(text_x, text_y, text_element.text_content)
para.drawOn(c, draw_x, draw_y)
except Exception as e:
warning = f"Error rendering text box: {str(e)}"

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

View File

@ -109,6 +109,243 @@ def test_pdf_exporter_with_text():
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)
def test_pdf_exporter_facing_pages_alignment():
"""Test that double spreads align to facing pages"""
project = Project("Test Facing Pages")
@ -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()