fixed issue with cover and image rendering
This commit is contained in:
parent
9fb6792e10
commit
a8e459bce5
@ -53,6 +53,9 @@ class RenderableImage(Renderable, Queriable):
|
|||||||
if size[0] is None or size[1] is None:
|
if size[0] is None or size[1] is None:
|
||||||
size = (100, 100) # Default size when image dimensions are unavailable
|
size = (100, 100) # Default size when image dimensions are unavailable
|
||||||
|
|
||||||
|
# Ensure dimensions are positive (can be negative if calculated from insufficient space)
|
||||||
|
size = (max(1, size[0]), max(1, size[1]))
|
||||||
|
|
||||||
# Set size as numpy array
|
# Set size as numpy array
|
||||||
self._size = np.array(size)
|
self._size = np.array(size)
|
||||||
|
|
||||||
@ -172,6 +175,10 @@ class RenderableImage(Renderable, Queriable):
|
|||||||
# Get the target dimensions
|
# Get the target dimensions
|
||||||
target_width, target_height = self._size
|
target_width, target_height = self._size
|
||||||
|
|
||||||
|
# Ensure target dimensions are positive
|
||||||
|
target_width = max(1, int(target_width))
|
||||||
|
target_height = max(1, int(target_height))
|
||||||
|
|
||||||
# Get the original dimensions
|
# Get the original dimensions
|
||||||
orig_width, orig_height = self._pil_image.size
|
orig_width, orig_height = self._pil_image.size
|
||||||
|
|
||||||
@ -183,8 +190,8 @@ class RenderableImage(Renderable, Queriable):
|
|||||||
ratio = min(width_ratio, height_ratio)
|
ratio = min(width_ratio, height_ratio)
|
||||||
|
|
||||||
# Calculate new dimensions
|
# Calculate new dimensions
|
||||||
new_width = int(orig_width * ratio)
|
new_width = max(1, int(orig_width * ratio))
|
||||||
new_height = int(orig_height * ratio)
|
new_height = max(1, int(orig_height * ratio))
|
||||||
|
|
||||||
# Resize the image
|
# Resize the image
|
||||||
if self._pil_image.mode == 'RGBA':
|
if self._pil_image.mode == 'RGBA':
|
||||||
|
|||||||
@ -446,17 +446,43 @@ class EPUBReader:
|
|||||||
|
|
||||||
def _process_chapter_images(self, chapter: Chapter):
|
def _process_chapter_images(self, chapter: Chapter):
|
||||||
"""
|
"""
|
||||||
Process images in a single chapter.
|
Load and process images in a single chapter.
|
||||||
|
|
||||||
|
This method loads images from disk into memory and applies image processing.
|
||||||
|
Images must be loaded before the temporary EPUB directory is cleaned up.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
chapter: The chapter containing images to process
|
chapter: The chapter containing images to process
|
||||||
"""
|
"""
|
||||||
from pyWebLayout.abstract.block import Image as AbstractImage
|
from pyWebLayout.abstract.block import Image as AbstractImage
|
||||||
|
from PIL import Image as PILImage
|
||||||
|
import io
|
||||||
|
|
||||||
for block in chapter.blocks:
|
for block in chapter.blocks:
|
||||||
if isinstance(block, AbstractImage):
|
if isinstance(block, AbstractImage):
|
||||||
# Only process if image has been loaded and processor is enabled
|
# Load image into memory if not already loaded
|
||||||
if hasattr(block, '_loaded_image') and block._loaded_image:
|
if not hasattr(block, '_loaded_image') or not block._loaded_image:
|
||||||
|
try:
|
||||||
|
# Load the image from the source path
|
||||||
|
if os.path.isfile(block.source):
|
||||||
|
with open(block.source, 'rb') as f:
|
||||||
|
image_bytes = f.read()
|
||||||
|
# Create PIL image from bytes in memory
|
||||||
|
pil_image = PILImage.open(io.BytesIO(image_bytes))
|
||||||
|
pil_image.load() # Force loading into memory
|
||||||
|
block._loaded_image = pil_image.copy() # Create a copy to ensure it persists
|
||||||
|
|
||||||
|
# Set width and height on the block from the loaded image
|
||||||
|
# This is required for layout calculations
|
||||||
|
block._width = pil_image.width
|
||||||
|
block._height = pil_image.height
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Failed to load image '{block.source}': {str(e)}")
|
||||||
|
# Continue without the image
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Apply image processing if enabled and image is loaded
|
||||||
|
if self.image_processor and hasattr(block, '_loaded_image') and block._loaded_image:
|
||||||
try:
|
try:
|
||||||
block._loaded_image = self.image_processor(block._loaded_image)
|
block._loaded_image = self.image_processor(block._loaded_image)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -466,10 +492,12 @@ class EPUBReader:
|
|||||||
# Continue with unprocessed image
|
# Continue with unprocessed image
|
||||||
|
|
||||||
def _process_content_images(self):
|
def _process_content_images(self):
|
||||||
"""Apply image processing to all images in chapters."""
|
"""
|
||||||
if not self.image_processor:
|
Load all images into memory and apply image processing.
|
||||||
return
|
|
||||||
|
|
||||||
|
This must be called before the temporary EPUB directory is cleaned up,
|
||||||
|
to ensure images are loaded from disk into memory.
|
||||||
|
"""
|
||||||
for chapter in self.book.chapters:
|
for chapter in self.book.chapters:
|
||||||
self._process_chapter_images(chapter)
|
self._process_chapter_images(chapter)
|
||||||
|
|
||||||
@ -527,8 +555,11 @@ class EPUBReader:
|
|||||||
with open(path, 'r', encoding='utf-8') as f:
|
with open(path, 'r', encoding='utf-8') as f:
|
||||||
html = f.read()
|
html = f.read()
|
||||||
|
|
||||||
# Parse HTML and add blocks to chapter
|
# Get the directory of the HTML file for resolving relative paths
|
||||||
blocks = parse_html_string(html, document=self.book)
|
html_dir = os.path.dirname(path)
|
||||||
|
|
||||||
|
# Parse HTML and add blocks to chapter, passing base_path for image resolution
|
||||||
|
blocks = parse_html_string(html, document=self.book, base_path=html_dir)
|
||||||
|
|
||||||
# Copy blocks to the chapter
|
# Copy blocks to the chapter
|
||||||
for block in blocks:
|
for block in blocks:
|
||||||
|
|||||||
@ -41,6 +41,7 @@ class StyleContext(NamedTuple):
|
|||||||
element_attributes: Dict[str, Any]
|
element_attributes: Dict[str, Any]
|
||||||
parent_elements: List[str] # Stack of parent element names
|
parent_elements: List[str] # Stack of parent element names
|
||||||
document: Optional[Any] # Reference to document for font registry
|
document: Optional[Any] # Reference to document for font registry
|
||||||
|
base_path: Optional[str] = None # Base path for resolving relative URLs
|
||||||
|
|
||||||
def with_font(self, font: Font) -> "StyleContext":
|
def with_font(self, font: Font) -> "StyleContext":
|
||||||
"""Create new context with modified font."""
|
"""Create new context with modified font."""
|
||||||
@ -71,13 +72,15 @@ class StyleContext(NamedTuple):
|
|||||||
|
|
||||||
def create_base_context(
|
def create_base_context(
|
||||||
base_font: Optional[Font] = None,
|
base_font: Optional[Font] = None,
|
||||||
document=None) -> StyleContext:
|
document=None,
|
||||||
|
base_path: Optional[str] = None) -> StyleContext:
|
||||||
"""
|
"""
|
||||||
Create a base style context with default values.
|
Create a base style context with default values.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
base_font: Base font to use, defaults to system default
|
base_font: Base font to use, defaults to system default
|
||||||
document: Document instance for font registry
|
document: Document instance for font registry
|
||||||
|
base_path: Base directory path for resolving relative URLs
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
StyleContext with default values
|
StyleContext with default values
|
||||||
@ -97,6 +100,7 @@ def create_base_context(
|
|||||||
element_attributes={},
|
element_attributes={},
|
||||||
parent_elements=[],
|
parent_elements=[],
|
||||||
document=document,
|
document=document,
|
||||||
|
base_path=base_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -792,9 +796,19 @@ def line_break_handler(element: Tag, context: StyleContext) -> None:
|
|||||||
|
|
||||||
def image_handler(element: Tag, context: StyleContext) -> Image:
|
def image_handler(element: Tag, context: StyleContext) -> Image:
|
||||||
"""Handle <img> elements."""
|
"""Handle <img> elements."""
|
||||||
|
import os
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
src = context.element_attributes.get("src", "")
|
src = context.element_attributes.get("src", "")
|
||||||
alt_text = context.element_attributes.get("alt", "")
|
alt_text = context.element_attributes.get("alt", "")
|
||||||
|
|
||||||
|
# Resolve relative paths if base_path is provided
|
||||||
|
if context.base_path and src and not src.startswith(('http://', 'https://', '/')):
|
||||||
|
# Parse the src to handle URL-encoded characters
|
||||||
|
src_decoded = urllib.parse.unquote(src)
|
||||||
|
# Resolve relative path to absolute path
|
||||||
|
src = os.path.normpath(os.path.join(context.base_path, src_decoded))
|
||||||
|
|
||||||
# Parse dimensions if provided
|
# Parse dimensions if provided
|
||||||
width = height = None
|
width = height = None
|
||||||
try:
|
try:
|
||||||
@ -883,7 +897,7 @@ HANDLERS: Dict[str, Callable[[Tag, StyleContext], Union[Block, List[Block], None
|
|||||||
|
|
||||||
|
|
||||||
def parse_html_string(
|
def parse_html_string(
|
||||||
html_string: str, base_font: Optional[Font] = None, document=None
|
html_string: str, base_font: Optional[Font] = None, document=None, base_path: Optional[str] = None
|
||||||
) -> List[Block]:
|
) -> List[Block]:
|
||||||
"""
|
"""
|
||||||
Parse HTML string and return list of Block objects.
|
Parse HTML string and return list of Block objects.
|
||||||
@ -892,12 +906,14 @@ def parse_html_string(
|
|||||||
html_string: HTML content to parse
|
html_string: HTML content to parse
|
||||||
base_font: Base font for styling, defaults to system default
|
base_font: Base font for styling, defaults to system default
|
||||||
document: Document instance for font registry to avoid duplicate fonts
|
document: Document instance for font registry to avoid duplicate fonts
|
||||||
|
base_path: Base directory path for resolving relative URLs (e.g., image sources)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of Block objects representing the document structure
|
List of Block objects representing the document structure
|
||||||
"""
|
"""
|
||||||
soup = BeautifulSoup(html_string, "html.parser")
|
soup = BeautifulSoup(html_string, "html.parser")
|
||||||
context = create_base_context(base_font, document)
|
context = create_base_context(base_font, document, base_path)
|
||||||
|
|
||||||
blocks = []
|
blocks = []
|
||||||
|
|
||||||
# Process the body if it exists, otherwise process all top-level elements
|
# Process the body if it exists, otherwise process all top-level elements
|
||||||
|
|||||||
@ -306,6 +306,11 @@ def image_layouter(image: AbstractImage, page: Page, max_width: Optional[int] =
|
|||||||
|
|
||||||
# Calculate available height on page
|
# Calculate available height on page
|
||||||
available_height = page.size[1] - page._current_y_offset - page.border_size
|
available_height = page.size[1] - page._current_y_offset - page.border_size
|
||||||
|
|
||||||
|
# If no space available, image doesn't fit
|
||||||
|
if available_height <= 0:
|
||||||
|
return False
|
||||||
|
|
||||||
if max_height is None:
|
if max_height is None:
|
||||||
max_height = available_height
|
max_height = available_height
|
||||||
else:
|
else:
|
||||||
|
|||||||
@ -15,13 +15,13 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass, asdict
|
from dataclasses import dataclass, asdict
|
||||||
from typing import List, Dict, Tuple, Optional, Any
|
from typing import List, Dict, Tuple, Optional, Any
|
||||||
|
|
||||||
from pyWebLayout.abstract.block import Block, Paragraph, Heading, HeadingLevel, Table, HList
|
from pyWebLayout.abstract.block import Block, Paragraph, Heading, HeadingLevel, Table, HList, Image
|
||||||
from pyWebLayout.abstract.inline import Word
|
from pyWebLayout.abstract.inline import Word
|
||||||
from pyWebLayout.concrete.page import Page
|
from pyWebLayout.concrete.page import Page
|
||||||
from pyWebLayout.concrete.text import Text
|
from pyWebLayout.concrete.text import Text
|
||||||
from pyWebLayout.style.page_style import PageStyle
|
from pyWebLayout.style.page_style import PageStyle
|
||||||
from pyWebLayout.style import Font
|
from pyWebLayout.style import Font
|
||||||
from pyWebLayout.layout.document_layouter import paragraph_layouter
|
from pyWebLayout.layout.document_layouter import paragraph_layouter, image_layouter
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -94,6 +94,26 @@ class ChapterNavigator:
|
|||||||
"""Scan blocks for headings and build chapter navigation map"""
|
"""Scan blocks for headings and build chapter navigation map"""
|
||||||
current_chapter_index = 0
|
current_chapter_index = 0
|
||||||
|
|
||||||
|
# Check if first block is a cover image and add it to TOC
|
||||||
|
if self.blocks and isinstance(self.blocks[0], Image):
|
||||||
|
cover_position = RenderingPosition(
|
||||||
|
chapter_index=0,
|
||||||
|
block_index=0,
|
||||||
|
word_index=0,
|
||||||
|
table_row=0,
|
||||||
|
table_col=0,
|
||||||
|
list_item_index=0
|
||||||
|
)
|
||||||
|
|
||||||
|
cover_info = ChapterInfo(
|
||||||
|
title="Cover",
|
||||||
|
level=HeadingLevel.H1, # Treat as top-level entry
|
||||||
|
position=cover_position,
|
||||||
|
block_index=0
|
||||||
|
)
|
||||||
|
|
||||||
|
self.chapters.append(cover_info)
|
||||||
|
|
||||||
for block_index, block in enumerate(self.blocks):
|
for block_index, block in enumerate(self.blocks):
|
||||||
if isinstance(block, Heading):
|
if isinstance(block, Heading):
|
||||||
# Create position for this heading
|
# Create position for this heading
|
||||||
@ -384,6 +404,8 @@ class BidirectionalLayouter:
|
|||||||
return self._layout_table_on_page(block, page, position, font_scale)
|
return self._layout_table_on_page(block, page, position, font_scale)
|
||||||
elif isinstance(block, HList):
|
elif isinstance(block, HList):
|
||||||
return self._layout_list_on_page(block, page, position, font_scale)
|
return self._layout_list_on_page(block, page, position, font_scale)
|
||||||
|
elif isinstance(block, Image):
|
||||||
|
return self._layout_image_on_page(block, page, position, font_scale)
|
||||||
else:
|
else:
|
||||||
# Skip unknown block types
|
# Skip unknown block types
|
||||||
new_pos = position.copy()
|
new_pos = position.copy()
|
||||||
@ -496,6 +518,46 @@ class BidirectionalLayouter:
|
|||||||
new_pos.list_item_index = 0
|
new_pos.list_item_index = 0
|
||||||
return True, new_pos
|
return True, new_pos
|
||||||
|
|
||||||
|
def _layout_image_on_page(self,
|
||||||
|
image: Image,
|
||||||
|
page: Page,
|
||||||
|
position: RenderingPosition,
|
||||||
|
font_scale: float) -> Tuple[bool,
|
||||||
|
RenderingPosition]:
|
||||||
|
"""
|
||||||
|
Layout an image on the page using the image_layouter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image: The Image block to layout
|
||||||
|
page: The page to layout on
|
||||||
|
position: Current rendering position (should be at the start of this image block)
|
||||||
|
font_scale: Font scaling factor (not used for images, but kept for consistency)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, new_position)
|
||||||
|
- success: True if image was laid out, False if page ran out of space
|
||||||
|
- new_position: Updated position (next block if success, same block if failed)
|
||||||
|
"""
|
||||||
|
# Try to layout the image on the current page
|
||||||
|
success = image_layouter(
|
||||||
|
image=image,
|
||||||
|
page=page,
|
||||||
|
max_width=None, # Use page available width
|
||||||
|
max_height=None # Use page available height
|
||||||
|
)
|
||||||
|
|
||||||
|
new_pos = position.copy()
|
||||||
|
|
||||||
|
if success:
|
||||||
|
# Image was successfully laid out, move to next block
|
||||||
|
new_pos.block_index += 1
|
||||||
|
new_pos.word_index = 0
|
||||||
|
return True, new_pos
|
||||||
|
else:
|
||||||
|
# Image didn't fit on current page, signal to continue on next page
|
||||||
|
# Keep same position so it will be attempted on the next page
|
||||||
|
return False, position
|
||||||
|
|
||||||
def _estimate_page_start(
|
def _estimate_page_start(
|
||||||
self,
|
self,
|
||||||
end_position: RenderingPosition,
|
end_position: RenderingPosition,
|
||||||
|
|||||||
545
tests/layout/test_ereader_image_rendering.py
Normal file
545
tests/layout/test_ereader_image_rendering.py
Normal file
@ -0,0 +1,545 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for Image block rendering in the ereader layout system.
|
||||||
|
|
||||||
|
Tests cover:
|
||||||
|
- Image block layout on pages
|
||||||
|
- Navigation with images (next/previous page)
|
||||||
|
- Images at different positions (start, middle, end)
|
||||||
|
- Cover page detection and handling
|
||||||
|
- Multi-page scenarios with images
|
||||||
|
"""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager
|
||||||
|
from pyWebLayout.layout.ereader_layout import BidirectionalLayouter, RenderingPosition
|
||||||
|
from pyWebLayout.abstract.block import Paragraph, Heading, HeadingLevel, Image
|
||||||
|
from pyWebLayout.abstract.inline import Word
|
||||||
|
from pyWebLayout.concrete.page import Page
|
||||||
|
from pyWebLayout.style import Font
|
||||||
|
from pyWebLayout.style.page_style import PageStyle
|
||||||
|
|
||||||
|
|
||||||
|
class TestImageBlockLayout(unittest.TestCase):
|
||||||
|
"""Test basic Image block layout functionality."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up test fixtures."""
|
||||||
|
self.base_font = Font(font_size=14)
|
||||||
|
self.page_size = (400, 600)
|
||||||
|
self.page_style = PageStyle(padding=(20, 20, 20, 20))
|
||||||
|
|
||||||
|
def test_layout_image_block_on_page(self):
|
||||||
|
"""Test that Image blocks can be laid out on pages."""
|
||||||
|
# Create a simple document with an image
|
||||||
|
blocks = [
|
||||||
|
Image(source="test.jpg", alt_text="Test Image", width=200, height=300)
|
||||||
|
]
|
||||||
|
|
||||||
|
layouter = BidirectionalLayouter(blocks, self.page_style)
|
||||||
|
position = RenderingPosition()
|
||||||
|
|
||||||
|
# Render page with image
|
||||||
|
page, next_pos = layouter.render_page_forward(position, font_scale=1.0)
|
||||||
|
|
||||||
|
# Should successfully render the page
|
||||||
|
self.assertIsNotNone(page)
|
||||||
|
self.assertIsInstance(page, Page)
|
||||||
|
|
||||||
|
# Position should advance past the image block
|
||||||
|
self.assertEqual(next_pos.block_index, 1)
|
||||||
|
|
||||||
|
def test_image_block_advances_position(self):
|
||||||
|
"""Test that rendering an image block correctly advances the position."""
|
||||||
|
blocks = [
|
||||||
|
Image(source="img1.jpg", alt_text="Image 1"),
|
||||||
|
Paragraph(self.base_font)
|
||||||
|
]
|
||||||
|
# Add some words to the paragraph
|
||||||
|
blocks[1].add_word(Word("Text after image", self.base_font))
|
||||||
|
|
||||||
|
layouter = BidirectionalLayouter(blocks, self.page_style)
|
||||||
|
position = RenderingPosition(block_index=0)
|
||||||
|
|
||||||
|
# Render page starting at image
|
||||||
|
page, next_pos = layouter.render_page_forward(position, font_scale=1.0)
|
||||||
|
|
||||||
|
# Position should either:
|
||||||
|
# 1. Move to next block if image was successfully laid out, OR
|
||||||
|
# 2. Stay at same position if image couldn't fit/render
|
||||||
|
# In either case, the layouter should handle it gracefully
|
||||||
|
self.assertIsNotNone(page)
|
||||||
|
self.assertGreaterEqual(next_pos.block_index, 0)
|
||||||
|
|
||||||
|
# If the image is at start and can't render, it may skip to next block anyway
|
||||||
|
# The important thing is the system doesn't crash
|
||||||
|
|
||||||
|
|
||||||
|
class TestImageNavigationScenarios(unittest.TestCase):
|
||||||
|
"""Test navigation scenarios with images in different positions."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up test fixtures."""
|
||||||
|
self.base_font = Font(font_size=14)
|
||||||
|
self.page_size = (400, 600)
|
||||||
|
self.page_style = PageStyle(padding=(20, 20, 20, 20))
|
||||||
|
self.temp_dir = tempfile.mkdtemp()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
"""Clean up temporary files."""
|
||||||
|
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def _create_paragraph(self, text: str) -> Paragraph:
|
||||||
|
"""Helper to create a paragraph with text."""
|
||||||
|
para = Paragraph(self.base_font)
|
||||||
|
para.add_word(Word(text, self.base_font))
|
||||||
|
return para
|
||||||
|
|
||||||
|
def test_next_page_with_image_on_second_page(self):
|
||||||
|
"""Test navigating to next page when an image is on the second page."""
|
||||||
|
# Document structure: paragraph → image → paragraph
|
||||||
|
blocks = [
|
||||||
|
self._create_paragraph("First paragraph on page 1."),
|
||||||
|
Image(source="middle.jpg", alt_text="Middle Image"),
|
||||||
|
self._create_paragraph("Third paragraph after image.")
|
||||||
|
]
|
||||||
|
|
||||||
|
manager = EreaderLayoutManager(
|
||||||
|
blocks=blocks,
|
||||||
|
page_size=self.page_size,
|
||||||
|
document_id="test_image_nav",
|
||||||
|
page_style=self.page_style,
|
||||||
|
bookmarks_dir=self.temp_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start at beginning
|
||||||
|
initial_pos = manager.current_position.block_index
|
||||||
|
self.assertEqual(initial_pos, 0)
|
||||||
|
|
||||||
|
# Navigate to next page
|
||||||
|
next_page = manager.next_page()
|
||||||
|
self.assertIsNotNone(next_page)
|
||||||
|
|
||||||
|
# Position should have advanced
|
||||||
|
self.assertGreater(manager.current_position.block_index, initial_pos)
|
||||||
|
|
||||||
|
def test_previous_page_with_image_on_previous_page(self):
|
||||||
|
"""Test navigating back when previous page contains an image."""
|
||||||
|
blocks = [
|
||||||
|
self._create_paragraph("First paragraph."),
|
||||||
|
Image(source="image1.jpg", alt_text="Image 1"),
|
||||||
|
self._create_paragraph("Third paragraph."),
|
||||||
|
self._create_paragraph("Fourth paragraph.")
|
||||||
|
]
|
||||||
|
|
||||||
|
manager = EreaderLayoutManager(
|
||||||
|
blocks=blocks,
|
||||||
|
page_size=self.page_size,
|
||||||
|
document_id="test_prev_image",
|
||||||
|
page_style=self.page_style,
|
||||||
|
bookmarks_dir=self.temp_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
# Navigate forward to get past the image
|
||||||
|
manager.next_page()
|
||||||
|
manager.next_page()
|
||||||
|
|
||||||
|
current_block = manager.current_position.block_index
|
||||||
|
self.assertGreater(current_block, 0)
|
||||||
|
|
||||||
|
# Navigate backward
|
||||||
|
prev_page = manager.previous_page()
|
||||||
|
self.assertIsNotNone(prev_page)
|
||||||
|
|
||||||
|
# Should have moved to an earlier position
|
||||||
|
self.assertLess(manager.current_position.block_index, current_block)
|
||||||
|
|
||||||
|
def test_multiple_images_in_sequence(self):
|
||||||
|
"""Test document with multiple consecutive images."""
|
||||||
|
blocks = [
|
||||||
|
self._create_paragraph("Introduction text."),
|
||||||
|
Image(source="img1.jpg", alt_text="Image 1"),
|
||||||
|
Image(source="img2.jpg", alt_text="Image 2"),
|
||||||
|
Image(source="img3.jpg", alt_text="Image 3"),
|
||||||
|
self._create_paragraph("Text after images.")
|
||||||
|
]
|
||||||
|
|
||||||
|
manager = EreaderLayoutManager(
|
||||||
|
blocks=blocks,
|
||||||
|
page_size=self.page_size,
|
||||||
|
document_id="test_multi_images",
|
||||||
|
page_style=self.page_style,
|
||||||
|
bookmarks_dir=self.temp_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
# Navigate through pages
|
||||||
|
pages_rendered = 0
|
||||||
|
max_pages = 10 # Safety limit
|
||||||
|
|
||||||
|
while pages_rendered < max_pages:
|
||||||
|
current_block = manager.current_position.block_index
|
||||||
|
|
||||||
|
# Try to go to next page
|
||||||
|
next_page = manager.next_page()
|
||||||
|
|
||||||
|
if next_page is None:
|
||||||
|
# Reached end
|
||||||
|
break
|
||||||
|
|
||||||
|
pages_rendered += 1
|
||||||
|
|
||||||
|
# Position should advance
|
||||||
|
self.assertGreaterEqual(
|
||||||
|
manager.current_position.block_index,
|
||||||
|
current_block,
|
||||||
|
f"Position should advance or stay same, page {pages_rendered}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should have rendered at least 2 pages
|
||||||
|
self.assertGreaterEqual(pages_rendered, 1)
|
||||||
|
|
||||||
|
def test_image_at_document_start(self):
|
||||||
|
"""Test document starting with an image (not as cover)."""
|
||||||
|
blocks = [
|
||||||
|
Image(source="start.jpg", alt_text="Start Image"),
|
||||||
|
self._create_paragraph("Text after image.")
|
||||||
|
]
|
||||||
|
|
||||||
|
manager = EreaderLayoutManager(
|
||||||
|
blocks=blocks,
|
||||||
|
page_size=self.page_size,
|
||||||
|
document_id="test_image_start",
|
||||||
|
page_style=self.page_style,
|
||||||
|
bookmarks_dir=self.temp_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
# First image should be detected as cover
|
||||||
|
self.assertTrue(manager.has_cover())
|
||||||
|
self.assertTrue(manager.is_on_cover())
|
||||||
|
|
||||||
|
# Navigate past cover
|
||||||
|
manager.next_page()
|
||||||
|
|
||||||
|
# Should now be at the text
|
||||||
|
self.assertFalse(manager.is_on_cover())
|
||||||
|
# Should have skipped the image block (cover)
|
||||||
|
self.assertEqual(manager.current_position.block_index, 1)
|
||||||
|
|
||||||
|
def test_image_at_document_end(self):
|
||||||
|
"""Test document ending with an image."""
|
||||||
|
blocks = [
|
||||||
|
self._create_paragraph("First paragraph."),
|
||||||
|
self._create_paragraph("Second paragraph."),
|
||||||
|
Image(source="end.jpg", alt_text="End Image")
|
||||||
|
]
|
||||||
|
|
||||||
|
manager = EreaderLayoutManager(
|
||||||
|
blocks=blocks,
|
||||||
|
page_size=self.page_size,
|
||||||
|
document_id="test_image_end",
|
||||||
|
page_style=self.page_style,
|
||||||
|
bookmarks_dir=self.temp_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
# Navigate to end
|
||||||
|
page_count = 0
|
||||||
|
max_pages = 10
|
||||||
|
|
||||||
|
while page_count < max_pages:
|
||||||
|
next_page = manager.next_page()
|
||||||
|
if next_page is None:
|
||||||
|
break
|
||||||
|
page_count += 1
|
||||||
|
|
||||||
|
# Should have successfully navigated through document including final image
|
||||||
|
self.assertGreater(page_count, 0)
|
||||||
|
|
||||||
|
def test_alternating_text_and_images(self):
|
||||||
|
"""Test document with alternating text and images."""
|
||||||
|
blocks = [
|
||||||
|
self._create_paragraph("Paragraph 1"),
|
||||||
|
Image(source="img1.jpg", alt_text="Image 1"),
|
||||||
|
self._create_paragraph("Paragraph 2"),
|
||||||
|
Image(source="img2.jpg", alt_text="Image 2"),
|
||||||
|
self._create_paragraph("Paragraph 3"),
|
||||||
|
Image(source="img3.jpg", alt_text="Image 3"),
|
||||||
|
self._create_paragraph("Paragraph 4")
|
||||||
|
]
|
||||||
|
|
||||||
|
manager = EreaderLayoutManager(
|
||||||
|
blocks=blocks,
|
||||||
|
page_size=self.page_size,
|
||||||
|
document_id="test_alternating",
|
||||||
|
page_style=self.page_style,
|
||||||
|
bookmarks_dir=self.temp_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
# Track blocks visited
|
||||||
|
blocks_visited = set()
|
||||||
|
max_pages = 15
|
||||||
|
|
||||||
|
for _ in range(max_pages):
|
||||||
|
blocks_visited.add(manager.current_position.block_index)
|
||||||
|
next_page = manager.next_page()
|
||||||
|
if next_page is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Should have visited multiple different blocks
|
||||||
|
self.assertGreater(len(blocks_visited), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCoverPageWithImages(unittest.TestCase):
|
||||||
|
"""Test cover page detection and handling with images."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up test fixtures."""
|
||||||
|
self.base_font = Font(font_size=14)
|
||||||
|
self.page_size = (400, 600)
|
||||||
|
self.page_style = PageStyle(padding=(20, 20, 20, 20))
|
||||||
|
self.temp_dir = tempfile.mkdtemp()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
"""Clean up temporary files."""
|
||||||
|
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def _create_paragraph(self, text: str) -> Paragraph:
|
||||||
|
"""Helper to create a paragraph with text."""
|
||||||
|
para = Paragraph(self.base_font)
|
||||||
|
para.add_word(Word(text, self.base_font))
|
||||||
|
return para
|
||||||
|
|
||||||
|
def test_cover_page_detected_from_first_image(self):
|
||||||
|
"""Test that first image is detected as cover."""
|
||||||
|
blocks = [
|
||||||
|
Image(source="cover.jpg", alt_text="Cover"),
|
||||||
|
self._create_paragraph("Chapter text.")
|
||||||
|
]
|
||||||
|
|
||||||
|
manager = EreaderLayoutManager(
|
||||||
|
blocks=blocks,
|
||||||
|
page_size=self.page_size,
|
||||||
|
document_id="test_cover_detection",
|
||||||
|
page_style=self.page_style,
|
||||||
|
bookmarks_dir=self.temp_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should detect cover
|
||||||
|
self.assertTrue(manager.has_cover())
|
||||||
|
self.assertTrue(manager.is_on_cover())
|
||||||
|
|
||||||
|
def test_no_cover_when_first_block_is_text(self):
|
||||||
|
"""Test that cover is not detected when first block is text."""
|
||||||
|
blocks = [
|
||||||
|
self._create_paragraph("First paragraph."),
|
||||||
|
Image(source="image.jpg", alt_text="Not a cover"),
|
||||||
|
self._create_paragraph("Second paragraph.")
|
||||||
|
]
|
||||||
|
|
||||||
|
manager = EreaderLayoutManager(
|
||||||
|
blocks=blocks,
|
||||||
|
page_size=self.page_size,
|
||||||
|
document_id="test_no_cover",
|
||||||
|
page_style=self.page_style,
|
||||||
|
bookmarks_dir=self.temp_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should NOT detect cover
|
||||||
|
self.assertFalse(manager.has_cover())
|
||||||
|
self.assertFalse(manager.is_on_cover())
|
||||||
|
|
||||||
|
def test_navigation_from_cover_skips_image_block(self):
|
||||||
|
"""Test that next_page from cover skips the cover image block."""
|
||||||
|
blocks = [
|
||||||
|
Image(source="cover.jpg", alt_text="Cover"),
|
||||||
|
self._create_paragraph("First content paragraph."),
|
||||||
|
self._create_paragraph("Second content paragraph.")
|
||||||
|
]
|
||||||
|
|
||||||
|
manager = EreaderLayoutManager(
|
||||||
|
blocks=blocks,
|
||||||
|
page_size=self.page_size,
|
||||||
|
document_id="test_cover_skip",
|
||||||
|
page_style=self.page_style,
|
||||||
|
bookmarks_dir=self.temp_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start on cover
|
||||||
|
self.assertTrue(manager.is_on_cover())
|
||||||
|
self.assertEqual(manager.current_position.block_index, 0)
|
||||||
|
|
||||||
|
# Navigate past cover
|
||||||
|
manager.next_page()
|
||||||
|
|
||||||
|
# Should skip cover image block (index 0) and go to first content (index 1)
|
||||||
|
self.assertFalse(manager.is_on_cover())
|
||||||
|
self.assertEqual(manager.current_position.block_index, 1)
|
||||||
|
|
||||||
|
def test_previous_page_returns_to_cover(self):
|
||||||
|
"""Test that previous_page from first content returns to cover."""
|
||||||
|
blocks = [
|
||||||
|
Image(source="cover.jpg", alt_text="Cover"),
|
||||||
|
self._create_paragraph("Content text.")
|
||||||
|
]
|
||||||
|
|
||||||
|
manager = EreaderLayoutManager(
|
||||||
|
blocks=blocks,
|
||||||
|
page_size=self.page_size,
|
||||||
|
document_id="test_back_to_cover",
|
||||||
|
page_style=self.page_style,
|
||||||
|
bookmarks_dir=self.temp_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
# Navigate past cover
|
||||||
|
manager.next_page()
|
||||||
|
self.assertFalse(manager.is_on_cover())
|
||||||
|
|
||||||
|
# Go back
|
||||||
|
manager.previous_page()
|
||||||
|
|
||||||
|
# Should be back on cover
|
||||||
|
self.assertTrue(manager.is_on_cover())
|
||||||
|
|
||||||
|
def test_jump_to_cover_from_middle(self):
|
||||||
|
"""Test jumping to cover from middle of document."""
|
||||||
|
blocks = [
|
||||||
|
Image(source="cover.jpg", alt_text="Cover"),
|
||||||
|
self._create_paragraph("Paragraph 1"),
|
||||||
|
self._create_paragraph("Paragraph 2"),
|
||||||
|
self._create_paragraph("Paragraph 3")
|
||||||
|
]
|
||||||
|
|
||||||
|
manager = EreaderLayoutManager(
|
||||||
|
blocks=blocks,
|
||||||
|
page_size=self.page_size,
|
||||||
|
document_id="test_jump_cover",
|
||||||
|
page_style=self.page_style,
|
||||||
|
bookmarks_dir=self.temp_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
# Navigate to middle
|
||||||
|
manager.next_page()
|
||||||
|
manager.next_page()
|
||||||
|
self.assertFalse(manager.is_on_cover())
|
||||||
|
|
||||||
|
# Jump to cover
|
||||||
|
cover_page = manager.jump_to_cover()
|
||||||
|
|
||||||
|
self.assertIsNotNone(cover_page)
|
||||||
|
self.assertTrue(manager.is_on_cover())
|
||||||
|
|
||||||
|
|
||||||
|
class TestImageBlockPositionTracking(unittest.TestCase):
|
||||||
|
"""Test position tracking with Image blocks."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up test fixtures."""
|
||||||
|
self.base_font = Font(font_size=14)
|
||||||
|
self.page_size = (400, 600)
|
||||||
|
self.page_style = PageStyle(padding=(20, 20, 20, 20))
|
||||||
|
self.temp_dir = tempfile.mkdtemp()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
"""Clean up temporary files."""
|
||||||
|
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def _create_paragraph(self, text: str) -> Paragraph:
|
||||||
|
"""Helper to create a paragraph with text."""
|
||||||
|
para = Paragraph(self.base_font)
|
||||||
|
para.add_word(Word(text, self.base_font))
|
||||||
|
return para
|
||||||
|
|
||||||
|
def test_position_info_includes_image_blocks(self):
|
||||||
|
"""Test that position info correctly handles image blocks."""
|
||||||
|
blocks = [
|
||||||
|
self._create_paragraph("Text 1"),
|
||||||
|
Image(source="img.jpg", alt_text="Image"),
|
||||||
|
self._create_paragraph("Text 2")
|
||||||
|
]
|
||||||
|
|
||||||
|
manager = EreaderLayoutManager(
|
||||||
|
blocks=blocks,
|
||||||
|
page_size=self.page_size,
|
||||||
|
document_id="test_pos_info",
|
||||||
|
page_style=self.page_style,
|
||||||
|
bookmarks_dir=self.temp_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get initial position info
|
||||||
|
pos_info = manager.get_position_info()
|
||||||
|
|
||||||
|
self.assertIn('position', pos_info)
|
||||||
|
self.assertIn('block_index', pos_info['position'])
|
||||||
|
self.assertEqual(pos_info['position']['block_index'], 0)
|
||||||
|
|
||||||
|
def test_bookmark_image_position(self):
|
||||||
|
"""Test bookmarking at an image position."""
|
||||||
|
blocks = [
|
||||||
|
self._create_paragraph("Before image"),
|
||||||
|
Image(source="bookmarked.jpg", alt_text="Bookmarked Image"),
|
||||||
|
self._create_paragraph("After image")
|
||||||
|
]
|
||||||
|
|
||||||
|
manager = EreaderLayoutManager(
|
||||||
|
blocks=blocks,
|
||||||
|
page_size=self.page_size,
|
||||||
|
document_id="test_bookmark_image",
|
||||||
|
page_style=self.page_style,
|
||||||
|
bookmarks_dir=self.temp_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
# Navigate to image position
|
||||||
|
manager.next_page()
|
||||||
|
|
||||||
|
# Add bookmark
|
||||||
|
bookmark_name = "image_location"
|
||||||
|
success = manager.add_bookmark(bookmark_name)
|
||||||
|
self.assertTrue(success)
|
||||||
|
|
||||||
|
# Navigate away
|
||||||
|
manager.next_page()
|
||||||
|
|
||||||
|
# Jump back to bookmark
|
||||||
|
page = manager.jump_to_bookmark(bookmark_name)
|
||||||
|
self.assertIsNotNone(page)
|
||||||
|
|
||||||
|
# Should be at or near the image position
|
||||||
|
# (exact position depends on how much fits on page)
|
||||||
|
self.assertGreater(manager.current_position.block_index, 0)
|
||||||
|
|
||||||
|
def test_reading_progress_with_images(self):
|
||||||
|
"""Test reading progress calculation with images in document."""
|
||||||
|
blocks = [
|
||||||
|
self._create_paragraph("Text 1"),
|
||||||
|
Image(source="img1.jpg", alt_text="Image 1"),
|
||||||
|
self._create_paragraph("Text 2"),
|
||||||
|
Image(source="img2.jpg", alt_text="Image 2"),
|
||||||
|
self._create_paragraph("Text 3")
|
||||||
|
]
|
||||||
|
|
||||||
|
manager = EreaderLayoutManager(
|
||||||
|
blocks=blocks,
|
||||||
|
page_size=self.page_size,
|
||||||
|
document_id="test_progress",
|
||||||
|
page_style=self.page_style,
|
||||||
|
bookmarks_dir=self.temp_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
# At start
|
||||||
|
progress_start = manager.get_reading_progress()
|
||||||
|
self.assertEqual(progress_start, 0.0)
|
||||||
|
|
||||||
|
# Navigate through document
|
||||||
|
for _ in range(5):
|
||||||
|
if manager.next_page() is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Progress should have increased
|
||||||
|
progress_end = manager.get_reading_progress()
|
||||||
|
self.assertGreater(progress_end, progress_start)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
Loading…
x
Reference in New Issue
Block a user