Added loading funcs for images
This commit is contained in:
parent
ec4070a083
commit
4029fdff96
@ -1,5 +1,10 @@
|
||||
from typing import List, Iterator, Tuple, Dict, Optional, Union, Any
|
||||
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
|
||||
|
||||
|
||||
@ -1219,6 +1224,160 @@ class Image(Block):
|
||||
height = max_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):
|
||||
|
||||
@ -5,6 +5,12 @@ Tests the core abstract block classes that form the foundation of the document m
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import os
|
||||
import tempfile
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
from PIL import Image as PILImage
|
||||
from pyWebLayout.abstract.block import (
|
||||
Block, BlockType, Paragraph, Heading, HeadingLevel, Quote, CodeBlock,
|
||||
HList, ListStyle, ListItem, Table, TableRow, TableCell,
|
||||
@ -13,6 +19,13 @@ from pyWebLayout.abstract.block import (
|
||||
from pyWebLayout.abstract.inline import Word, LineBreak
|
||||
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):
|
||||
"""Test cases for basic block elements."""
|
||||
@ -271,5 +284,289 @@ class TestBlockElements(unittest.TestCase):
|
||||
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__':
|
||||
unittest.main()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user