added tests for images
Some checks failed
Python CI / test (push) Failing after 43s

Added loading funcs for images
This commit is contained in:
Duncan Tourolle 2025-06-07 18:23:06 +02:00
parent ec4070a083
commit 4029fdff96
2 changed files with 456 additions and 0 deletions

View File

@ -1,5 +1,10 @@
from typing import List, Iterator, Tuple, Dict, Optional, Union, Any from typing import List, Iterator, Tuple, Dict, Optional, Union, Any
from enum import Enum from enum import Enum
import os
import tempfile
import urllib.request
import urllib.parse
from PIL import Image as PILImage
from .inline import Word, FormattedSpan from .inline import Word, FormattedSpan
@ -1219,6 +1224,160 @@ class Image(Block):
height = max_height height = max_height
return (width, height) return (width, height)
def _is_url(self, source: str) -> bool:
"""
Check if the source is a URL.
Args:
source: The source string to check
Returns:
True if the source appears to be a URL, False otherwise
"""
parsed = urllib.parse.urlparse(source)
return bool(parsed.scheme and parsed.netloc)
def _download_to_temp(self, url: str) -> str:
"""
Download an image from a URL to a temporary file.
Args:
url: The URL to download from
Returns:
Path to the temporary file
Raises:
urllib.error.URLError: If the download fails
"""
# Create a temporary file
temp_fd, temp_path = tempfile.mkstemp(suffix='.tmp')
try:
# Download the image
with urllib.request.urlopen(url) as response:
# Write the response data to the temporary file
with os.fdopen(temp_fd, 'wb') as temp_file:
temp_file.write(response.read())
return temp_path
except:
# Clean up the temporary file if download fails
try:
os.close(temp_fd)
except:
pass
try:
os.unlink(temp_path)
except:
pass
raise
def load_image_data(self, auto_update_dimensions: bool = True) -> Tuple[Optional[str], Optional[PILImage.Image]]:
"""
Load image data using PIL, handling both local files and URLs.
Args:
auto_update_dimensions: If True, automatically update width and height from the loaded image
Returns:
Tuple of (file_path, PIL_Image_object). For URLs, file_path is the temporary file path.
Returns (None, None) if loading fails.
"""
if not self._source:
return None, None
file_path = None
temp_file = None
try:
if self._is_url(self._source):
# Download to temporary file
temp_file = self._download_to_temp(self._source)
file_path = temp_file
else:
# Use local file path
file_path = self._source
# Open with PIL
with PILImage.open(file_path) as img:
# Load the image data
img.load()
# Update dimensions if requested
if auto_update_dimensions:
self._width, self._height = img.size
# Return a copy to avoid issues with the context manager
return file_path, img.copy()
except Exception as e:
# Clean up temporary file on error
if temp_file and os.path.exists(temp_file):
try:
os.unlink(temp_file)
except:
pass
return None, None
def get_image_info(self) -> Dict[str, Any]:
"""
Get detailed information about the image using PIL.
Returns:
Dictionary containing image information including format, mode, size, etc.
Returns empty dict if image cannot be loaded.
"""
file_path, img = self.load_image_data(auto_update_dimensions=False)
if img is None:
return {}
# Try to determine format from the image, file extension, or source
img_format = img.format
if img_format is None:
# Try to determine format from file extension
format_map = {
'.jpg': 'JPEG',
'.jpeg': 'JPEG',
'.png': 'PNG',
'.gif': 'GIF',
'.bmp': 'BMP',
'.tiff': 'TIFF',
'.tif': 'TIFF'
}
# First try the actual file path if available
if file_path:
ext = os.path.splitext(file_path)[1].lower()
img_format = format_map.get(ext)
# If still no format and we have a URL source, try the original URL
if img_format is None and self._is_url(self._source):
ext = os.path.splitext(urllib.parse.urlparse(self._source).path)[1].lower()
img_format = format_map.get(ext)
info = {
'format': img_format,
'mode': img.mode,
'size': img.size,
'width': img.width,
'height': img.height,
}
# Add additional info if available
if hasattr(img, 'info'):
info['info'] = img.info
# Clean up temporary file if it was created
if file_path and self._is_url(self._source):
try:
os.unlink(file_path)
except:
pass
return info
class HorizontalRule(Block): class HorizontalRule(Block):

View File

@ -5,6 +5,12 @@ Tests the core abstract block classes that form the foundation of the document m
""" """
import unittest import unittest
import os
import tempfile
import shutil
import threading
import time
from PIL import Image as PILImage
from pyWebLayout.abstract.block import ( from pyWebLayout.abstract.block import (
Block, BlockType, Paragraph, Heading, HeadingLevel, Quote, CodeBlock, Block, BlockType, Paragraph, Heading, HeadingLevel, Quote, CodeBlock,
HList, ListStyle, ListItem, Table, TableRow, TableCell, HList, ListStyle, ListItem, Table, TableRow, TableCell,
@ -13,6 +19,13 @@ from pyWebLayout.abstract.block import (
from pyWebLayout.abstract.inline import Word, LineBreak from pyWebLayout.abstract.inline import Word, LineBreak
from pyWebLayout.style import Font from pyWebLayout.style import Font
# Flask server for testing URL functionality
try:
from flask import Flask, send_file
FLASK_AVAILABLE = True
except ImportError:
FLASK_AVAILABLE = False
class TestBlockElements(unittest.TestCase): class TestBlockElements(unittest.TestCase):
"""Test cases for basic block elements.""" """Test cases for basic block elements."""
@ -271,5 +284,289 @@ class TestBlockElements(unittest.TestCase):
self.assertIsNone(br.parent) self.assertIsNone(br.parent)
class TestImagePIL(unittest.TestCase):
"""Test cases for Image class with PIL functionality."""
@classmethod
def setUpClass(cls):
"""Set up temporary directory and test images."""
cls.temp_dir = tempfile.mkdtemp()
cls.sample_image_path = "tests/data/sample_image.jpg"
# Create test images in different formats
cls._create_test_images()
# Start Flask server for URL testing if Flask is available
if FLASK_AVAILABLE:
cls._start_flask_server()
@classmethod
def tearDownClass(cls):
"""Clean up temporary directory and stop Flask server."""
shutil.rmtree(cls.temp_dir, ignore_errors=True)
if FLASK_AVAILABLE and hasattr(cls, 'flask_thread'):
cls.flask_server_running = False
cls.flask_thread.join(timeout=2)
@classmethod
def _create_test_images(cls):
"""Create test images in different formats."""
# Load the sample image
if os.path.exists(cls.sample_image_path):
with PILImage.open(cls.sample_image_path) as img:
cls.original_size = img.size
# Save in different formats
cls.jpg_path = os.path.join(cls.temp_dir, "test.jpg")
cls.png_path = os.path.join(cls.temp_dir, "test.png")
cls.bmp_path = os.path.join(cls.temp_dir, "test.bmp")
cls.gif_path = os.path.join(cls.temp_dir, "test.gif")
img.save(cls.jpg_path, "JPEG")
img.save(cls.png_path, "PNG")
img.save(cls.bmp_path, "BMP")
# Convert to RGB for GIF (GIF doesn't support transparency from RGBA)
rgb_img = img.convert("RGB")
rgb_img.save(cls.gif_path, "GIF")
else:
# Create a simple test image if sample doesn't exist
cls.original_size = (100, 100)
test_img = PILImage.new("RGB", cls.original_size, (255, 0, 0))
cls.jpg_path = os.path.join(cls.temp_dir, "test.jpg")
cls.png_path = os.path.join(cls.temp_dir, "test.png")
cls.bmp_path = os.path.join(cls.temp_dir, "test.bmp")
cls.gif_path = os.path.join(cls.temp_dir, "test.gif")
test_img.save(cls.jpg_path, "JPEG")
test_img.save(cls.png_path, "PNG")
test_img.save(cls.bmp_path, "BMP")
test_img.save(cls.gif_path, "GIF")
@classmethod
def _start_flask_server(cls):
"""Start a Flask server for URL testing."""
cls.flask_app = Flask(__name__)
cls.flask_port = 5555 # Use a specific port for testing
cls.flask_server_running = True
@cls.flask_app.route('/test.jpg')
def serve_test_image():
return send_file(cls.jpg_path, mimetype='image/jpeg')
def run_flask():
cls.flask_app.run(host='127.0.0.1', port=cls.flask_port, debug=False,
use_reloader=False, threaded=True)
cls.flask_thread = threading.Thread(target=run_flask, daemon=True)
cls.flask_thread.start()
# Wait for server to start
time.sleep(1)
def test_image_url_detection(self):
"""Test URL detection functionality."""
img = Image()
# Test URL detection
self.assertTrue(img._is_url("http://example.com/image.jpg"))
self.assertTrue(img._is_url("https://example.com/image.png"))
self.assertTrue(img._is_url("ftp://example.com/image.gif"))
# Test non-URL detection
self.assertFalse(img._is_url("image.jpg"))
self.assertFalse(img._is_url("/path/to/image.png"))
self.assertFalse(img._is_url("../relative/path.gif"))
self.assertFalse(img._is_url(""))
def test_load_local_image_jpg(self):
"""Test loading local JPG image."""
img = Image(self.jpg_path)
file_path, pil_img = img.load_image_data()
self.assertIsNotNone(pil_img)
self.assertEqual(file_path, self.jpg_path)
self.assertEqual(pil_img.size, self.original_size)
self.assertEqual(img.width, self.original_size[0])
self.assertEqual(img.height, self.original_size[1])
def test_load_local_image_png(self):
"""Test loading local PNG image."""
img = Image(self.png_path)
file_path, pil_img = img.load_image_data()
self.assertIsNotNone(pil_img)
self.assertEqual(file_path, self.png_path)
self.assertEqual(pil_img.size, self.original_size)
def test_load_local_image_bmp(self):
"""Test loading local BMP image."""
img = Image(self.bmp_path)
file_path, pil_img = img.load_image_data()
self.assertIsNotNone(pil_img)
self.assertEqual(file_path, self.bmp_path)
self.assertEqual(pil_img.size, self.original_size)
def test_load_local_image_gif(self):
"""Test loading local GIF image."""
img = Image(self.gif_path)
file_path, pil_img = img.load_image_data()
self.assertIsNotNone(pil_img)
self.assertEqual(file_path, self.gif_path)
self.assertEqual(pil_img.size, self.original_size)
def test_load_nonexistent_image(self):
"""Test loading non-existent image."""
img = Image("nonexistent.jpg")
file_path, pil_img = img.load_image_data()
self.assertIsNone(pil_img)
self.assertIsNone(file_path)
def test_load_empty_source(self):
"""Test loading with empty source."""
img = Image("")
file_path, pil_img = img.load_image_data()
self.assertIsNone(pil_img)
self.assertIsNone(file_path)
def test_auto_update_dimensions(self):
"""Test automatic dimension updating."""
img = Image(self.jpg_path, width=50, height=50) # Wrong initial dimensions
# Test with auto-update enabled (default)
file_path, pil_img = img.load_image_data(auto_update_dimensions=True)
self.assertEqual(img.width, self.original_size[0])
self.assertEqual(img.height, self.original_size[1])
def test_no_auto_update_dimensions(self):
"""Test loading without automatic dimension updating."""
original_width, original_height = 50, 50
img = Image(self.jpg_path, width=original_width, height=original_height)
# Test with auto-update disabled
file_path, pil_img = img.load_image_data(auto_update_dimensions=False)
self.assertEqual(img.width, original_width) # Should remain unchanged
self.assertEqual(img.height, original_height) # Should remain unchanged
def test_get_image_info(self):
"""Test getting detailed image information."""
img = Image(self.jpg_path)
info = img.get_image_info()
self.assertIsInstance(info, dict)
self.assertIn('format', info)
self.assertIn('mode', info)
self.assertIn('size', info)
self.assertIn('width', info)
self.assertIn('height', info)
self.assertEqual(info['size'], self.original_size)
self.assertEqual(info['width'], self.original_size[0])
self.assertEqual(info['height'], self.original_size[1])
def test_get_image_info_different_formats(self):
"""Test getting image info for different formats."""
formats_and_paths = [
('JPEG', self.jpg_path),
('PNG', self.png_path),
('BMP', self.bmp_path),
('GIF', self.gif_path),
]
for expected_format, path in formats_and_paths:
with self.subTest(format=expected_format):
img = Image(path)
info = img.get_image_info()
self.assertEqual(info['format'], expected_format)
self.assertEqual(info['size'], self.original_size)
def test_get_image_info_nonexistent(self):
"""Test getting image info for non-existent image."""
img = Image("nonexistent.jpg")
info = img.get_image_info()
self.assertEqual(info, {})
@unittest.skipUnless(FLASK_AVAILABLE, "Flask not available for URL testing")
def test_load_image_from_url(self):
"""Test loading image from URL."""
url = f"http://127.0.0.1:{self.flask_port}/test.jpg"
img = Image(url)
file_path, pil_img = img.load_image_data()
self.assertIsNotNone(pil_img)
self.assertIsNotNone(file_path)
self.assertTrue(file_path.endswith('.tmp')) # Should be a temp file
self.assertEqual(pil_img.size, self.original_size)
# Check that dimensions were updated
self.assertEqual(img.width, self.original_size[0])
self.assertEqual(img.height, self.original_size[1])
@unittest.skipUnless(FLASK_AVAILABLE, "Flask not available for URL testing")
def test_get_image_info_from_url(self):
"""Test getting image info from URL."""
url = f"http://127.0.0.1:{self.flask_port}/test.jpg"
img = Image(url)
info = img.get_image_info()
self.assertIsInstance(info, dict)
self.assertEqual(info['format'], 'JPEG')
self.assertEqual(info['size'], self.original_size)
def test_load_invalid_url(self):
"""Test loading from invalid URL."""
img = Image("http://nonexistent.domain/image.jpg")
file_path, pil_img = img.load_image_data()
self.assertIsNone(pil_img)
self.assertIsNone(file_path)
def test_multiple_loads_cleanup(self):
"""Test that multiple loads don't leave temp files."""
img = Image(self.jpg_path)
# Load multiple times
for _ in range(3):
file_path, pil_img = img.load_image_data()
self.assertIsNotNone(pil_img)
def test_original_sample_image(self):
"""Test loading the original sample image if it exists."""
if os.path.exists(self.sample_image_path):
img = Image(self.sample_image_path)
file_path, pil_img = img.load_image_data()
self.assertIsNotNone(pil_img)
self.assertEqual(file_path, self.sample_image_path)
# Test that we can get image info
info = img.get_image_info()
self.assertIsInstance(info, dict)
self.assertIn('format', info)
self.assertIn('size', info)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()