This commit is contained in:
@@ -14,7 +14,7 @@ __version__ = '0.1.0'
|
||||
from pyWebLayout.core import Renderable, Interactable, Layoutable, Queriable
|
||||
|
||||
# Style components
|
||||
from pyWebLayout.style import Alignment, Font, FontWeight, FontStyle, TextDecoration
|
||||
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
|
||||
|
||||
|
||||
# Abstract document model
|
||||
|
||||
@@ -166,7 +166,12 @@ class Paragraph(Block):
|
||||
"""
|
||||
return FormattedSpan.create_and_add_to(self, style, background)
|
||||
|
||||
def words(self) -> Iterator[Tuple[int, Word]]:
|
||||
@property
|
||||
def words(self) -> List[Word]:
|
||||
"""Get the list of words in this paragraph"""
|
||||
return self._words
|
||||
|
||||
def words_iter(self) -> Iterator[Tuple[int, Word]]:
|
||||
"""
|
||||
Iterate over the words in this paragraph.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from PIL import Image
|
||||
from typing import Tuple, Union, List, Optional, Dict
|
||||
|
||||
from pyWebLayout.core.base import Renderable, Queriable
|
||||
from pyWebLayout.style.layout import Alignment
|
||||
from pyWebLayout.style import Alignment
|
||||
|
||||
class Box(Renderable, Queriable):
|
||||
|
||||
@@ -21,7 +21,16 @@ class Box(Renderable, Queriable):
|
||||
self._halign = halign
|
||||
self._valign = valign
|
||||
|
||||
@property
|
||||
def origin(self) -> np.ndarray:
|
||||
"""Get the origin (top-left corner) of the box"""
|
||||
return self._origin
|
||||
|
||||
@property
|
||||
def size(self) -> np.ndarray:
|
||||
"""Get the size (width, height) of the box"""
|
||||
return self._size
|
||||
|
||||
def in_shape(self, point):
|
||||
|
||||
return np.all((point >= self._origin) & (point < self._end), axis=-1)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from PIL import Image as PILImage, ImageDraw, ImageFont
|
||||
from pyWebLayout.core.base import Renderable, Queriable
|
||||
from pyWebLayout.abstract.block import Image as AbstractImage
|
||||
from .box import Box
|
||||
from pyWebLayout.style.layout import Alignment
|
||||
from pyWebLayout.style import Alignment
|
||||
|
||||
|
||||
class RenderableImage(Renderable, Queriable):
|
||||
|
||||
@@ -4,21 +4,20 @@ from PIL import Image, ImageDraw
|
||||
|
||||
from pyWebLayout.core.base import Renderable, Layoutable, Queriable
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.style.layout import Alignment
|
||||
from pyWebLayout.style import Alignment
|
||||
from .box import Box
|
||||
|
||||
|
||||
class Page(Renderable, Queriable):
|
||||
"""
|
||||
A page represents a canvas that can hold and render child renderable objects.
|
||||
It handles layout, rendering, and provides query capabilities to find which child
|
||||
contains a given point.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, size: Tuple[int, int], style: Optional[PageStyle] = None):
|
||||
"""
|
||||
Initialize a new page.
|
||||
|
||||
|
||||
Args:
|
||||
size: The total size of the page (width, height) including borders
|
||||
style: The PageStyle defining borders, spacing, and appearance
|
||||
@@ -29,16 +28,21 @@ class Page(Renderable, Queriable):
|
||||
self._canvas: Optional[Image.Image] = None
|
||||
self._draw: Optional[ImageDraw.Draw] = None
|
||||
self._current_y_offset = 0 # Track vertical position for layout
|
||||
|
||||
|
||||
def free_space(self) -> Tuple[int, int]:
|
||||
"""Get the remaining space on the page"""
|
||||
return (self._size[0], self._size[1] - self._current_y_offset)
|
||||
|
||||
def can_fit_line(self, line_height: int) -> bool:
|
||||
"""Check if a line of the given height can fit on the page."""
|
||||
remaining_height = self.content_size[1] - (self._current_y_offset - self._style.border_width - self._style.padding_top)
|
||||
return remaining_height >= line_height
|
||||
|
||||
@property
|
||||
def size(self) -> Tuple[int, int]:
|
||||
"""Get the total page size including borders"""
|
||||
return self._size
|
||||
|
||||
|
||||
@property
|
||||
def canvas_size(self) -> Tuple[int, int]:
|
||||
"""Get the canvas size (page size minus borders)"""
|
||||
@@ -47,7 +51,7 @@ class Page(Renderable, Queriable):
|
||||
self._size[0] - border_reduction,
|
||||
self._size[1] - border_reduction
|
||||
)
|
||||
|
||||
|
||||
@property
|
||||
def content_size(self) -> Tuple[int, int]:
|
||||
"""Get the content area size (canvas minus padding)"""
|
||||
@@ -56,29 +60,33 @@ class Page(Renderable, Queriable):
|
||||
canvas_w - self._style.total_horizontal_padding,
|
||||
canvas_h - self._style.total_vertical_padding
|
||||
)
|
||||
|
||||
|
||||
@property
|
||||
def border_size(self) -> int:
|
||||
"""Get the border width"""
|
||||
return self._style.border_width
|
||||
|
||||
|
||||
@property
|
||||
def style(self) -> PageStyle:
|
||||
"""Get the page style"""
|
||||
return self._style
|
||||
|
||||
|
||||
@property
|
||||
def draw(self) -> Optional[ImageDraw.Draw]:
|
||||
"""Get the ImageDraw object for drawing on this page's canvas"""
|
||||
if self._draw is None:
|
||||
# Initialize canvas and draw context if not already done
|
||||
self._canvas = self._create_canvas()
|
||||
self._draw = ImageDraw.Draw(self._canvas)
|
||||
return self._draw
|
||||
|
||||
|
||||
def add_child(self, child: Renderable) -> 'Page':
|
||||
"""
|
||||
Add a child renderable object to this page.
|
||||
|
||||
|
||||
Args:
|
||||
child: The renderable object to add
|
||||
|
||||
|
||||
Returns:
|
||||
Self for method chaining
|
||||
"""
|
||||
@@ -87,14 +95,14 @@ class Page(Renderable, Queriable):
|
||||
# Invalidate the canvas when children change
|
||||
self._canvas = None
|
||||
return self
|
||||
|
||||
|
||||
def remove_child(self, child: Renderable) -> bool:
|
||||
"""
|
||||
Remove a child from the page.
|
||||
|
||||
|
||||
Args:
|
||||
child: The child to remove
|
||||
|
||||
|
||||
Returns:
|
||||
True if the child was found and removed, False otherwise
|
||||
"""
|
||||
@@ -104,11 +112,11 @@ class Page(Renderable, Queriable):
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def clear_children(self) -> 'Page':
|
||||
"""
|
||||
Remove all children from the page.
|
||||
|
||||
|
||||
Returns:
|
||||
Self for method chaining
|
||||
"""
|
||||
@@ -116,93 +124,95 @@ class Page(Renderable, Queriable):
|
||||
self._canvas = None
|
||||
self._current_y_offset = 0
|
||||
return self
|
||||
|
||||
|
||||
@property
|
||||
def children(self) -> List[Renderable]:
|
||||
"""Get a copy of the children list"""
|
||||
return self._children.copy()
|
||||
|
||||
|
||||
def _get_child_height(self, child: Renderable) -> int:
|
||||
"""
|
||||
Get the height of a child object.
|
||||
|
||||
|
||||
Args:
|
||||
child: The child to measure
|
||||
|
||||
|
||||
Returns:
|
||||
Height in pixels
|
||||
"""
|
||||
if hasattr(child, '_size') and child._size is not None:
|
||||
if isinstance(child._size, (list, tuple, np.ndarray)) and len(child._size) >= 2:
|
||||
return int(child._size[1])
|
||||
|
||||
|
||||
if hasattr(child, 'size') and child.size is not None:
|
||||
if isinstance(child.size, (list, tuple, np.ndarray)) and len(child.size) >= 2:
|
||||
return int(child.size[1])
|
||||
|
||||
|
||||
if hasattr(child, 'height'):
|
||||
return int(child.height)
|
||||
|
||||
|
||||
# Default fallback height
|
||||
return 20
|
||||
|
||||
|
||||
def render_children(self):
|
||||
"""
|
||||
Call render on all children in the list.
|
||||
Children draw directly onto the page's canvas via the shared ImageDraw object.
|
||||
"""
|
||||
for child in self._children:
|
||||
# Synchronize draw context for Line objects before rendering
|
||||
if hasattr(child, '_draw'):
|
||||
child._draw = self._draw
|
||||
if hasattr(child, 'render'):
|
||||
child.render()
|
||||
|
||||
|
||||
def render(self) -> Image.Image:
|
||||
"""
|
||||
Render the page with all its children.
|
||||
|
||||
|
||||
Returns:
|
||||
PIL Image containing the rendered page
|
||||
"""
|
||||
# Create the base canvas and draw object
|
||||
self._canvas = self._create_canvas()
|
||||
self._draw = ImageDraw.Draw(self._canvas)
|
||||
|
||||
|
||||
# Render all children - they draw directly onto the canvas
|
||||
self.render_children()
|
||||
|
||||
|
||||
return self._canvas
|
||||
|
||||
|
||||
def _create_canvas(self) -> Image.Image:
|
||||
"""
|
||||
Create the base canvas with background and borders.
|
||||
|
||||
|
||||
Returns:
|
||||
PIL Image with background and borders applied
|
||||
"""
|
||||
# Create base image
|
||||
canvas = Image.new('RGBA', self._size, (*self._style.background_color, 255))
|
||||
|
||||
|
||||
# Draw borders if needed
|
||||
if self._style.border_width > 0:
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
border_color = (*self._style.border_color, 255)
|
||||
|
||||
# Draw border rectangle
|
||||
for i in range(self._style.border_width):
|
||||
draw.rectangle([
|
||||
(i, i),
|
||||
(self._size[0] - 1 - i, self._size[1] - 1 - i)
|
||||
], outline=border_color)
|
||||
|
||||
|
||||
# Draw border rectangle inside the content area
|
||||
border_offset = self._style.border_width
|
||||
draw.rectangle([
|
||||
(border_offset, border_offset),
|
||||
(self._size[0] - border_offset - 1, self._size[1] - border_offset - 1)
|
||||
], outline=border_color)
|
||||
|
||||
return canvas
|
||||
|
||||
|
||||
def _get_child_position(self, child: Renderable) -> Tuple[int, int]:
|
||||
"""
|
||||
Get the position where a child should be rendered.
|
||||
|
||||
|
||||
Args:
|
||||
child: The child object
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (x, y) coordinates
|
||||
"""
|
||||
@@ -211,42 +221,42 @@ class Page(Renderable, Queriable):
|
||||
return (int(child._origin[0]), int(child._origin[1]))
|
||||
elif isinstance(child._origin, (list, tuple)) and len(child._origin) >= 2:
|
||||
return (int(child._origin[0]), int(child._origin[1]))
|
||||
|
||||
|
||||
if hasattr(child, 'position'):
|
||||
pos = child.position
|
||||
if isinstance(pos, (list, tuple)) and len(pos) >= 2:
|
||||
return (int(pos[0]), int(pos[1]))
|
||||
|
||||
|
||||
# Default to origin
|
||||
return (0, 0)
|
||||
|
||||
|
||||
def query_point(self, point: Tuple[int, int]) -> Optional[Renderable]:
|
||||
"""
|
||||
Query a point to determine which child it belongs to.
|
||||
|
||||
|
||||
Args:
|
||||
point: The (x, y) coordinates to query
|
||||
|
||||
|
||||
Returns:
|
||||
The child object that contains the point, or None if no child contains it
|
||||
"""
|
||||
point_array = np.array(point)
|
||||
|
||||
|
||||
# Check each child (in reverse order so topmost child is found first)
|
||||
for child in reversed(self._children):
|
||||
if self._point_in_child(point_array, child):
|
||||
return child
|
||||
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _point_in_child(self, point: np.ndarray, child: Renderable) -> bool:
|
||||
"""
|
||||
Check if a point is within a child's bounds.
|
||||
|
||||
|
||||
Args:
|
||||
point: The point to check
|
||||
child: The child to check against
|
||||
|
||||
|
||||
Returns:
|
||||
True if the point is within the child's bounds
|
||||
"""
|
||||
@@ -256,50 +266,50 @@ class Page(Renderable, Queriable):
|
||||
return child.in_object(point)
|
||||
except:
|
||||
pass # Fall back to bounds checking
|
||||
|
||||
|
||||
# Get child position and size for bounds checking
|
||||
child_pos = self._get_child_position(child)
|
||||
child_size = self._get_child_size(child)
|
||||
|
||||
|
||||
if child_size is None:
|
||||
return False
|
||||
|
||||
|
||||
# Check if point is within child bounds
|
||||
return (
|
||||
child_pos[0] <= point[0] < child_pos[0] + child_size[0] and
|
||||
child_pos[1] <= point[1] < child_pos[1] + child_size[1]
|
||||
)
|
||||
|
||||
|
||||
def _get_child_size(self, child: Renderable) -> Optional[Tuple[int, int]]:
|
||||
"""
|
||||
Get the size of a child object.
|
||||
|
||||
|
||||
Args:
|
||||
child: The child to measure
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (width, height) or None if size cannot be determined
|
||||
"""
|
||||
if hasattr(child, '_size') and child._size is not None:
|
||||
if isinstance(child._size, (list, tuple, np.ndarray)) and len(child._size) >= 2:
|
||||
return (int(child._size[0]), int(child._size[1]))
|
||||
|
||||
|
||||
if hasattr(child, 'size') and child.size is not None:
|
||||
if isinstance(child.size, (list, tuple, np.ndarray)) and len(child.size) >= 2:
|
||||
return (int(child.size[0]), int(child.size[1]))
|
||||
|
||||
|
||||
if hasattr(child, 'width') and hasattr(child, 'height'):
|
||||
return (int(child.width), int(child.height))
|
||||
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def in_object(self, point: Tuple[int, int]) -> bool:
|
||||
"""
|
||||
Check if a point is within this page's bounds.
|
||||
|
||||
|
||||
Args:
|
||||
point: The (x, y) coordinates to check
|
||||
|
||||
|
||||
Returns:
|
||||
True if the point is within the page bounds
|
||||
"""
|
||||
|
||||
@@ -1,44 +1,40 @@
|
||||
from __future__ import annotations
|
||||
from pyWebLayout.core.base import Renderable, Queriable
|
||||
from .box import Box
|
||||
from pyWebLayout.style.layout import Alignment
|
||||
from pyWebLayout.style import Font, FontStyle, FontWeight, TextDecoration
|
||||
from pyWebLayout.style import Alignment, Font, FontStyle, FontWeight, TextDecoration
|
||||
from pyWebLayout.abstract import Word
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from typing import Tuple, Union, List, Optional, Protocol
|
||||
import numpy as np
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class AlignmentHandler(ABC):
|
||||
"""
|
||||
Abstract base class for text alignment handlers.
|
||||
Each handler implements a specific alignment strategy.
|
||||
"""
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
||||
available_width: int, min_spacing: int,
|
||||
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
||||
available_width: int, min_spacing: int,
|
||||
max_spacing: int) -> Tuple[int, int, bool]:
|
||||
"""
|
||||
Calculate the spacing between words and starting position for the line.
|
||||
|
||||
|
||||
Args:
|
||||
text_objects: List of Text objects in the line
|
||||
available_width: Total width available for the line
|
||||
min_spacing: Minimum spacing between words
|
||||
max_spacing: Maximum spacing between words
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (spacing_between_words, starting_x_position)
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
|
||||
class LeftAlignmentHandler(AlignmentHandler):
|
||||
"""Handler for left-aligned text."""
|
||||
|
||||
|
||||
def calculate_spacing_and_position(self,
|
||||
text_objects: List['Text'],
|
||||
available_width: int,
|
||||
@@ -46,6 +42,7 @@ class LeftAlignmentHandler(AlignmentHandler):
|
||||
max_spacing: int) -> Tuple[int, int, bool]:
|
||||
"""
|
||||
Calculate spacing and position for left-aligned text objects.
|
||||
CREngine-inspired: never allow negative spacing, always use minimum spacing for overflow.
|
||||
|
||||
Args:
|
||||
text_objects (List[Text]): A list of text objects to be laid out.
|
||||
@@ -56,41 +53,52 @@ class LeftAlignmentHandler(AlignmentHandler):
|
||||
Returns:
|
||||
Tuple[int, int, bool]: Spacing, start position, and overflow flag.
|
||||
"""
|
||||
print("LeftAlignmentHandler:")
|
||||
# Handle single word case
|
||||
if len(text_objects) <= 1:
|
||||
return 0, 0, False
|
||||
|
||||
# Calculate the total length of all text objects
|
||||
text_length = sum([text.width for text in text_objects])
|
||||
|
||||
# Calculate number of gaps between texts
|
||||
num_gaps = len(text_objects) - 1
|
||||
|
||||
# Calculate minimum space needed (text + minimum gaps)
|
||||
min_total_width = text_length + (min_spacing * num_gaps)
|
||||
|
||||
# Check if we have overflow (CREngine pattern: always use min_spacing for overflow)
|
||||
if min_total_width > available_width:
|
||||
return min_spacing, 0, True # Overflow - but use safe minimum spacing
|
||||
|
||||
# Calculate residual space left after accounting for text lengths
|
||||
residual_space = available_width - text_length
|
||||
|
||||
# Calculate number of gaps between texts
|
||||
num_gaps = max(1, len(text_objects) - 1)
|
||||
|
||||
# Initial spacing based on equal distribution of residual space
|
||||
ideal_space = (min_spacing + max_spacing)/2
|
||||
# Calculate ideal spacing
|
||||
actual_spacing = residual_space // num_gaps
|
||||
|
||||
# Clamp the calculated spacing within min and max limits
|
||||
if actual_spacing < min_spacing:
|
||||
return actual_spacing, 0, True
|
||||
|
||||
return ideal_space, 0, False
|
||||
|
||||
|
||||
print(actual_spacing)
|
||||
# Clamp within bounds (CREngine pattern: respect max_spacing)
|
||||
if actual_spacing > max_spacing:
|
||||
return max_spacing, 0, False
|
||||
elif actual_spacing < min_spacing:
|
||||
# Ensure we never return spacing less than min_spacing
|
||||
return min_spacing, 0, False
|
||||
else:
|
||||
return actual_spacing, 0, False # Use calculated spacing
|
||||
|
||||
class CenterRightAlignmentHandler(AlignmentHandler):
|
||||
"""Handler for center and right-aligned text."""
|
||||
|
||||
|
||||
def __init__(self, alignment: Alignment):
|
||||
self._alignment = alignment
|
||||
|
||||
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
||||
available_width: int, min_spacing: int,
|
||||
|
||||
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
||||
available_width: int, min_spacing: int,
|
||||
max_spacing: int) -> Tuple[int, int, bool]:
|
||||
"""Center/right alignment uses minimum spacing with calculated start position."""
|
||||
word_length = sum([word.width for word in text_objects])
|
||||
residual_space = available_width - word_length
|
||||
|
||||
|
||||
# Handle single word case
|
||||
if len(text_objects) <= 1:
|
||||
if self._alignment == Alignment.CENTER:
|
||||
@@ -98,14 +106,13 @@ class CenterRightAlignmentHandler(AlignmentHandler):
|
||||
else: # RIGHT
|
||||
start_position = available_width - word_length
|
||||
return 0, max(0, start_position), False
|
||||
|
||||
actual_spacing = residual_space // (len(text_objects)-1)
|
||||
|
||||
actual_spacing = residual_space // (len(text_objects)-1)
|
||||
print(actual_spacing)
|
||||
ideal_space = (min_spacing + max_spacing)/2
|
||||
if actual_spacing > 0.5*(min_spacing + max_spacing):
|
||||
actual_spacing = 0.5*(min_spacing + max_spacing)
|
||||
|
||||
|
||||
content_length = word_length + (len(text_objects)-1) * actual_spacing
|
||||
if self._alignment == Alignment.CENTER:
|
||||
start_position = (available_width - content_length) // 2
|
||||
@@ -114,15 +121,14 @@ class CenterRightAlignmentHandler(AlignmentHandler):
|
||||
|
||||
if actual_spacing < min_spacing:
|
||||
return actual_spacing, max(0, start_position), True
|
||||
|
||||
return ideal_space, max(0, start_position), False
|
||||
|
||||
return ideal_space, max(0, start_position), False
|
||||
|
||||
class JustifyAlignmentHandler(AlignmentHandler):
|
||||
"""Handler for justified text with full justification."""
|
||||
|
||||
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
||||
available_width: int, min_spacing: int,
|
||||
|
||||
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
||||
available_width: int, min_spacing: int,
|
||||
max_spacing: int) -> Tuple[int, int, bool]:
|
||||
"""Justified alignment distributes space to fill the entire line width."""
|
||||
|
||||
@@ -132,17 +138,14 @@ class JustifyAlignmentHandler(AlignmentHandler):
|
||||
|
||||
actual_spacing = residual_space // num_gaps
|
||||
ideal_space = (min_spacing + max_spacing)//2
|
||||
|
||||
print(actual_spacing)
|
||||
# can we touch the end?
|
||||
if actual_spacing < max_spacing:
|
||||
if actual_spacing < min_spacing:
|
||||
# Ensure we never return spacing less than min_spacing
|
||||
return min_spacing, 0, True
|
||||
return actual_spacing, 0, False
|
||||
return ideal_space,0,False
|
||||
|
||||
|
||||
|
||||
|
||||
return max(min_spacing, actual_spacing), 0, False
|
||||
return ideal_space, 0, False
|
||||
|
||||
class Text(Renderable, Queriable):
|
||||
"""
|
||||
@@ -153,7 +156,7 @@ class Text(Renderable, Queriable):
|
||||
def __init__(self, text: str, style: Font, draw: ImageDraw.Draw, source: Optional[Word] = None, line: Optional[Line] = None):
|
||||
"""
|
||||
Initialize a Text object.
|
||||
|
||||
|
||||
Args:
|
||||
text: The text content to render
|
||||
style: The font style to use for rendering
|
||||
@@ -165,10 +168,10 @@ class Text(Renderable, Queriable):
|
||||
self._source = source
|
||||
self._origin = np.array([0, 0])
|
||||
self._draw = draw
|
||||
|
||||
|
||||
# Calculate dimensions
|
||||
self._calculate_dimensions()
|
||||
|
||||
|
||||
def _calculate_dimensions(self):
|
||||
"""Calculate the width and height of the text based on the font metrics"""
|
||||
# Get the size using PIL's text size functionality
|
||||
@@ -186,12 +189,12 @@ class Text(Renderable, Queriable):
|
||||
def text(self) -> str:
|
||||
"""Get the text content"""
|
||||
return self._text
|
||||
|
||||
|
||||
@property
|
||||
def style(self) -> Font:
|
||||
"""Get the text style"""
|
||||
return self._style
|
||||
|
||||
|
||||
@property
|
||||
def origin(self) -> np.ndarray:
|
||||
"""Get the origin of the text"""
|
||||
@@ -201,78 +204,74 @@ class Text(Renderable, Queriable):
|
||||
def line(self) -> Optional[Line]:
|
||||
"""Get the line containing this text"""
|
||||
return self._line
|
||||
|
||||
|
||||
@line.setter
|
||||
def line(self, line):
|
||||
"""Set the line containing this text"""
|
||||
self._line = line
|
||||
|
||||
|
||||
@property
|
||||
def width(self) -> int:
|
||||
"""Get the width of the text"""
|
||||
return self._width
|
||||
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
"""Get the width of the text"""
|
||||
return np.array((self._width, self._style.font_size))
|
||||
|
||||
|
||||
def set_origin(self, origin:np.generic):
|
||||
"""Set the origin (left baseline ("ls")) of this text element"""
|
||||
self._origin = origin
|
||||
|
||||
|
||||
def add_line(self, line):
|
||||
"""Add this text to a line"""
|
||||
self._line = line
|
||||
|
||||
|
||||
def _apply_decoration(self):
|
||||
"""Apply text decoration (underline or strikethrough)"""
|
||||
if self._style.decoration == TextDecoration.UNDERLINE:
|
||||
# Draw underline at about 90% of the height
|
||||
|
||||
y_position = self._origin[1] - 0.1*self._style.font_size
|
||||
self._draw.line([(0, y_position), (self._width, y_position)],
|
||||
self._draw.line([(0, y_position), (self._width, y_position)],
|
||||
fill=self._style.colour, width=max(1, int(self._style.font_size / 15)))
|
||||
|
||||
|
||||
elif self._style.decoration == TextDecoration.STRIKETHROUGH:
|
||||
# Draw strikethrough at about 50% of the height
|
||||
y_position = self._origin[1] + self._middle_y
|
||||
self._draw.line([(0, y_position), (self._width, y_position)],
|
||||
self._draw.line([(0, y_position), (self._width, y_position)],
|
||||
fill=self._style.colour, width=max(1, int(self._style.font_size / 15)))
|
||||
|
||||
|
||||
def render(self):
|
||||
"""
|
||||
Render the text to an image.
|
||||
|
||||
|
||||
Returns:
|
||||
A PIL Image containing the rendered text
|
||||
"""
|
||||
|
||||
|
||||
# Draw the text background if specified
|
||||
if self._style.background and self._style.background[3] > 0: # If alpha > 0
|
||||
self._draw.rectangle([self._origin, self._origin+self._size], fill=self._style.background)
|
||||
|
||||
|
||||
# Draw the text using calculated offsets to prevent cropping
|
||||
self._draw.text((self.origin[0], self._origin[1]), self._text, font=self._style.font,anchor="ls", fill=self._style.colour)
|
||||
|
||||
self._draw.text((self.origin[0], self._origin[1]), self._text, font=self._style.font, fill=self._style.colour)
|
||||
|
||||
# Apply any text decorations
|
||||
self._apply_decoration()
|
||||
|
||||
|
||||
|
||||
|
||||
class Line(Box):
|
||||
"""
|
||||
A line of text consisting of Text objects with consistent spacing.
|
||||
Each Text represents a word or word fragment that can be rendered.
|
||||
"""
|
||||
|
||||
def __init__(self, spacing: Tuple[int, int], origin, size, draw: ImageDraw.Draw,font: Optional[Font] = None,
|
||||
callback=None, sheet=None, mode=None, halign=Alignment.CENTER,
|
||||
def __init__(self, spacing: Tuple[int, int], origin, size, draw: ImageDraw.Draw,font: Optional[Font] = None,
|
||||
callback=None, sheet=None, mode=None, halign=Alignment.CENTER,
|
||||
valign=Alignment.CENTER, previous = None):
|
||||
"""
|
||||
Initialize a new line.
|
||||
|
||||
|
||||
Args:
|
||||
spacing: A tuple of (min_spacing, max_spacing) between words
|
||||
origin: The top-left position of the line
|
||||
@@ -298,20 +297,21 @@ class Line(Box):
|
||||
self._draw = draw
|
||||
self._spacing_render = (spacing[0] + spacing[1]) //2
|
||||
self._position_render = 0
|
||||
|
||||
|
||||
# Create the appropriate alignment handler
|
||||
self._alignment_handler = self._create_alignment_handler(halign)
|
||||
|
||||
def _create_alignment_handler(self, alignment: Alignment) -> AlignmentHandler:
|
||||
"""
|
||||
Create the appropriate alignment handler based on the alignment type.
|
||||
|
||||
|
||||
Args:
|
||||
alignment: The alignment type
|
||||
|
||||
|
||||
Returns:
|
||||
The appropriate alignment handler instance
|
||||
"""
|
||||
print("HALGIN!!!!!", alignment)
|
||||
if alignment == Alignment.LEFT:
|
||||
return LeftAlignmentHandler()
|
||||
elif alignment == Alignment.JUSTIFY:
|
||||
@@ -319,26 +319,23 @@ class Line(Box):
|
||||
else: # CENTER or RIGHT
|
||||
return CenterRightAlignmentHandler(alignment)
|
||||
|
||||
|
||||
|
||||
@property
|
||||
def text_objects(self) -> List[Text]:
|
||||
"""Get the list of Text objects in this line"""
|
||||
return self._text_objects
|
||||
|
||||
|
||||
def set_next(self, line: Line):
|
||||
"""Set the next line in sequence"""
|
||||
self._next = line
|
||||
|
||||
|
||||
def add_word(self, word: 'Word', part:Optional[Text]=None) -> Tuple[bool, Optional['Text']]:
|
||||
"""
|
||||
Add a word to this line using intelligent word fitting strategies.
|
||||
|
||||
|
||||
Args:
|
||||
text: The text content of the word
|
||||
font: The font to use for this word, or None to use the line's default font
|
||||
|
||||
|
||||
Returns:
|
||||
True if the word was successfully added, False if it couldn't fit, in case of hypenation the hyphenated part is returned
|
||||
"""
|
||||
@@ -350,7 +347,7 @@ class Line(Box):
|
||||
text = Text.from_word(word, self._draw)
|
||||
self._text_objects.append(text)
|
||||
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(self._text_objects, self._size[0],self._spacing[0], self._spacing[1])
|
||||
|
||||
print(self._alignment_handler)
|
||||
if not overflow:
|
||||
self._words.append(word)
|
||||
word.add_concete(text)
|
||||
@@ -358,11 +355,9 @@ class Line(Box):
|
||||
self._position_render = position
|
||||
self._spacing_render = spacing
|
||||
return True, None # no overflow word is just added!
|
||||
|
||||
|
||||
|
||||
_=self._text_objects.pop()
|
||||
splits = [(Text(pair[0], word.style,self._draw, line=self, source=word), Text( pair[1], word.style, self._draw, line=self, source=word)) for pair in word.possible_hyphenation()]
|
||||
splits = [(Text(pair[0]+"-", word.style,self._draw, line=self, source=word), Text( pair[1], word.style, self._draw, line=self, source=word)) for pair in word.possible_hyphenation()]
|
||||
|
||||
#worst case scenario!
|
||||
if len(splits)==0 and len(word.text)>=6:
|
||||
@@ -383,7 +378,7 @@ class Line(Box):
|
||||
|
||||
elif len(splits)==0 and len(word.text)<6:
|
||||
return False, None # this endpoint means no words can be added.
|
||||
|
||||
|
||||
spacings = []
|
||||
positions = []
|
||||
|
||||
@@ -403,26 +398,28 @@ class Line(Box):
|
||||
self._words.append(word)
|
||||
return True, splits[idx][1] # we apply a phyphenated split with best spacing
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def render(self):
|
||||
"""
|
||||
Render the line with all its text objects using the alignment handler system.
|
||||
|
||||
|
||||
Returns:
|
||||
A PIL Image containing the rendered line
|
||||
"""
|
||||
# Recalculate spacing and position for current text objects to ensure accuracy
|
||||
if len(self._text_objects) > 0:
|
||||
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
|
||||
self._text_objects, self._size[0], self._spacing[0], self._spacing[1]
|
||||
)
|
||||
self._spacing_render = spacing
|
||||
self._position_render = position
|
||||
|
||||
self._position_render # x-offset
|
||||
self._spacing_render # x-spacing
|
||||
y_cursor = self._origin[1] + self._baseline
|
||||
|
||||
x_cursor = self._position_render
|
||||
# Start x_cursor at line origin plus any alignment offset
|
||||
x_cursor = self._origin[0] + self._position_render
|
||||
for text in self._text_objects:
|
||||
|
||||
text.set_origin(np.array([x_cursor,y_cursor]))
|
||||
# Update text draw context to current draw context
|
||||
text._draw = self._draw
|
||||
text.set_origin(np.array([x_cursor, y_cursor]))
|
||||
text.render()
|
||||
x_cursor += self._spacing_render + text.width # x-spacing + width of text object
|
||||
|
||||
@@ -4,7 +4,7 @@ from PIL import Image
|
||||
|
||||
from pyWebLayout.core.base import Renderable, Layoutable
|
||||
from .box import Box
|
||||
from pyWebLayout.style.layout import Alignment
|
||||
from pyWebLayout.style import Alignment
|
||||
|
||||
|
||||
class Viewport(Box, Layoutable):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from abc import ABC
|
||||
import numpy as np
|
||||
|
||||
from pyWebLayout.style import Alignment
|
||||
from pyWebLayout.style.alignment import Alignment
|
||||
|
||||
|
||||
class Renderable(ABC):
|
||||
@@ -66,4 +66,4 @@ class Queriable(ABC):
|
||||
"""
|
||||
point_array = np.array(point)
|
||||
relative_point = point_array - self._origin
|
||||
return np.all((0 <= relative_point) & (relative_point < self.size))
|
||||
return np.all((0 <= relative_point) & (relative_point < self.size))
|
||||
|
||||
@@ -8,7 +8,7 @@ from pyWebLayout.concrete.text import (
|
||||
Line, Text,
|
||||
LeftAlignmentHandler, CenterRightAlignmentHandler, JustifyAlignmentHandler
|
||||
)
|
||||
from pyWebLayout.style.layout import Alignment
|
||||
from pyWebLayout.style import Alignment
|
||||
from pyWebLayout.style import Font
|
||||
|
||||
def demonstrate_handler_system():
|
||||
|
||||
@@ -12,7 +12,7 @@ from pyWebLayout.concrete import (
|
||||
Viewport, ScrollablePageContent, Text, Box, RenderableImage
|
||||
)
|
||||
from pyWebLayout.style.fonts import Font, FontWeight
|
||||
from pyWebLayout.style.layout import Alignment
|
||||
from pyWebLayout.style import Alignment
|
||||
|
||||
|
||||
def create_large_document_content():
|
||||
|
||||
@@ -29,7 +29,7 @@ from pyWebLayout.abstract.functional import (
|
||||
from pyWebLayout.abstract.block import Paragraph
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.style.fonts import Font, FontWeight, FontStyle, TextDecoration
|
||||
from pyWebLayout.style.layout import Alignment
|
||||
from pyWebLayout.style import Alignment
|
||||
from pyWebLayout.layout.paragraph_layout import ParagraphLayout, ParagraphLayoutResult
|
||||
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ from pyWebLayout.abstract.functional import (
|
||||
from pyWebLayout.abstract.block import Paragraph
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.style.fonts import Font, FontWeight, FontStyle, TextDecoration
|
||||
from pyWebLayout.style.layout import Alignment
|
||||
from pyWebLayout.style import Alignment
|
||||
from pyWebLayout.io.readers.html_extraction import parse_html_string
|
||||
|
||||
|
||||
|
||||
@@ -62,16 +62,10 @@ class EPUBReader:
|
||||
# Extract the EPUB file
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self._extract_epub()
|
||||
|
||||
# Parse the package document (content.opf)
|
||||
self._parse_package_document()
|
||||
|
||||
# Parse the table of contents
|
||||
self._parse_toc()
|
||||
|
||||
# Create a Book object
|
||||
self._create_book()
|
||||
|
||||
|
||||
# Add chapters to the book
|
||||
self._add_chapters()
|
||||
|
||||
@@ -377,7 +371,7 @@ class EPUBReader:
|
||||
html = f.read()
|
||||
|
||||
# Parse HTML and add blocks to chapter
|
||||
blocks = parse_html_string(html)
|
||||
blocks = parse_html_string(html, document=self.book)
|
||||
|
||||
# Copy blocks to the chapter
|
||||
for block in blocks:
|
||||
|
||||
@@ -27,7 +27,8 @@ from pyWebLayout.abstract.block import (
|
||||
Image,
|
||||
)
|
||||
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
|
||||
from pyWebLayout.style.abstract_style import AbstractStyle, FontFamily, FontSize, TextAlign
|
||||
from pyWebLayout.style.abstract_style import AbstractStyle, FontFamily, FontSize
|
||||
from pyWebLayout.style import Alignment as TextAlign
|
||||
|
||||
|
||||
class StyleContext(NamedTuple):
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Tuple, Optional
|
||||
from typing import List, Tuple, Optional, Union
|
||||
|
||||
from pyWebLayout.concrete import Page, Line, Text
|
||||
from pyWebLayout.abstract import Paragraph, Word, Link
|
||||
from pyWebLayout.style.concrete_style import ConcreteStyleRegistry
|
||||
|
||||
|
||||
def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pretext: Optional[Text] = None) -> Tuple[bool, Optional[int], Optional[Text]]:
|
||||
"""
|
||||
Layout a paragraph of text within a given page.
|
||||
|
||||
|
||||
This function extracts word spacing constraints from the style system
|
||||
and uses them to create properly spaced lines of text.
|
||||
|
||||
@@ -19,7 +18,7 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
|
||||
page: The page to layout the paragraph on
|
||||
start_word: Index of the first word to process (for continuation)
|
||||
pretext: Optional pretext from a previous hyphenated word
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of:
|
||||
- bool: True if paragraph was completely laid out, False if page ran out of space
|
||||
@@ -28,29 +27,36 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
|
||||
"""
|
||||
if not paragraph.words:
|
||||
return True, None, None
|
||||
|
||||
|
||||
# Validate inputs
|
||||
if start_word >= len(paragraph.words):
|
||||
return True, None, None
|
||||
|
||||
|
||||
# Get the concrete style with resolved word spacing constraints
|
||||
style_registry = ConcreteStyleRegistry(page.style_resolver)
|
||||
concrete_style = style_registry.get_concrete_style(paragraph.style)
|
||||
|
||||
|
||||
# Extract word spacing constraints (min, max) for Line constructor
|
||||
word_spacing_constraints = (
|
||||
int(concrete_style.word_spacing_min),
|
||||
int(concrete_style.word_spacing_max)
|
||||
)
|
||||
|
||||
def create_new_line() -> Optional[Line]:
|
||||
|
||||
def create_new_line(word: Optional[Union[Word, Text]] = None) -> Optional[Line]:
|
||||
"""Helper function to create a new line, returns None if page is full."""
|
||||
if not page.can_fit_line(paragraph.line_height):
|
||||
return None
|
||||
|
||||
|
||||
y_cursor = page._current_y_offset
|
||||
x_cursor = page.border_size
|
||||
|
||||
|
||||
# Create a temporary Text object to calculate word width
|
||||
if word:
|
||||
temp_text = Text.from_word(word, page.draw)
|
||||
word_width = temp_text.width
|
||||
else:
|
||||
word_width = 0
|
||||
|
||||
return Line(
|
||||
spacing=word_spacing_constraints,
|
||||
origin=(x_cursor, y_cursor),
|
||||
@@ -59,38 +65,67 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
|
||||
font=concrete_style.create_font(),
|
||||
halign=concrete_style.text_align
|
||||
)
|
||||
|
||||
|
||||
# Create initial line
|
||||
current_line = create_new_line()
|
||||
if not current_line:
|
||||
return False, start_word, pretext
|
||||
|
||||
|
||||
page.add_child(current_line)
|
||||
page._current_y_offset += paragraph.line_height
|
||||
|
||||
|
||||
# Track current position in paragraph
|
||||
current_pretext = pretext
|
||||
|
||||
|
||||
# Process words starting from start_word
|
||||
for i, word in enumerate(paragraph.words[start_word:], start=start_word):
|
||||
if current_pretext:
|
||||
print(current_pretext.text)
|
||||
success, overflow_text = current_line.add_word(word, current_pretext)
|
||||
|
||||
|
||||
if success:
|
||||
# Word fit successfully
|
||||
current_pretext = None # Clear pretext after successful placement
|
||||
if overflow_text is not None:
|
||||
# If there's overflow text, we need to start a new line with it
|
||||
current_pretext = overflow_text
|
||||
current_line = create_new_line(overflow_text)
|
||||
if not current_line:
|
||||
# If we can't create a new line, return with the current state
|
||||
return False, i, overflow_text
|
||||
page.add_child(current_line)
|
||||
page._current_y_offset += paragraph.line_height
|
||||
# Continue to the next word
|
||||
continue
|
||||
else:
|
||||
# No overflow, clear pretext
|
||||
current_pretext = None
|
||||
else:
|
||||
# Word didn't fit, need a new line
|
||||
current_line = create_new_line()
|
||||
current_line = create_new_line(word)
|
||||
if not current_line:
|
||||
# Page is full, return current position
|
||||
return False, i, overflow_text
|
||||
|
||||
|
||||
# Check if the word will fit on the new line before adding it
|
||||
temp_text = Text.from_word(word, page.draw)
|
||||
if temp_text.width > current_line.size[0]:
|
||||
# Word is too wide for the line, we need to hyphenate it
|
||||
if len(word.text) >= 6:
|
||||
# Try to hyphenate the word
|
||||
splits = [(Text(pair[0], word.style, page.draw, line=current_line, source=word), Text(pair[1], word.style, page.draw, line=current_line, source=word)) for pair in word.possible_hyphenation()]
|
||||
if len(splits) > 0:
|
||||
# Use the first hyphenation point
|
||||
first_part, second_part = splits[0]
|
||||
current_line.add_word(word, first_part)
|
||||
current_pretext = second_part
|
||||
continue
|
||||
|
||||
page.add_child(current_line)
|
||||
page._current_y_offset += paragraph.line_height
|
||||
|
||||
|
||||
# Try to add the word to the new line
|
||||
success, overflow_text = current_line.add_word(word, current_pretext)
|
||||
|
||||
|
||||
if not success:
|
||||
# Word still doesn't fit even on a new line
|
||||
# This might happen with very long words or narrow pages
|
||||
@@ -104,59 +139,58 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
|
||||
return False, i, None
|
||||
else:
|
||||
current_pretext = overflow_text # May be None or hyphenated remainder
|
||||
|
||||
|
||||
# All words processed successfully
|
||||
return True, None, None
|
||||
|
||||
|
||||
class DocumentLayouter:
|
||||
"""
|
||||
Class-based document layouter for more complex layout operations.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, page: Page):
|
||||
"""Initialize the layouter with a page."""
|
||||
self.page = page
|
||||
self.style_registry = ConcreteStyleRegistry(page.style_resolver)
|
||||
|
||||
|
||||
def layout_paragraph(self, paragraph: Paragraph, start_word: int = 0, pretext: Optional[Text] = None) -> Tuple[bool, Optional[int], Optional[Text]]:
|
||||
"""
|
||||
Layout a paragraph using the class-based approach.
|
||||
|
||||
|
||||
This method provides the same functionality as the standalone function
|
||||
but with better state management and reusability.
|
||||
"""
|
||||
return paragraph_layouter(paragraph, self.page, start_word, pretext)
|
||||
|
||||
|
||||
def layout_document(self, paragraphs: List[Paragraph]) -> bool:
|
||||
"""
|
||||
Layout multiple paragraphs in sequence.
|
||||
|
||||
|
||||
Args:
|
||||
paragraphs: List of paragraphs to layout
|
||||
|
||||
|
||||
Returns:
|
||||
True if all paragraphs were laid out successfully, False otherwise
|
||||
"""
|
||||
for paragraph in paragraphs:
|
||||
start_word = 0
|
||||
pretext = None
|
||||
|
||||
|
||||
while True:
|
||||
complete, next_word, remaining_pretext = self.layout_paragraph(
|
||||
paragraph, start_word, pretext
|
||||
)
|
||||
|
||||
|
||||
if complete:
|
||||
# Paragraph finished
|
||||
break
|
||||
|
||||
|
||||
if next_word is None:
|
||||
# Error condition
|
||||
return False
|
||||
|
||||
|
||||
# Continue on next page or handle page break
|
||||
# For now, we'll just return False indicating we need more space
|
||||
return False
|
||||
|
||||
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
"""
|
||||
Enhanced ereader layout system with position tracking, font scaling, and multi-page support.
|
||||
|
||||
This module provides the core infrastructure for building high-performance ereader applications
|
||||
with features like:
|
||||
- Precise position tracking tied to abstract document structure
|
||||
- Font scaling support
|
||||
- Bidirectional page rendering (forward/backward)
|
||||
- Chapter navigation based on HTML headings
|
||||
- Multi-process page buffering
|
||||
- Sub-second page rendering performance
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import List, Dict, Tuple, Optional, Union, Generator, Any
|
||||
from enum import Enum
|
||||
import json
|
||||
import multiprocessing
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
import threading
|
||||
import time
|
||||
|
||||
from pyWebLayout.abstract.block import Block, Paragraph, Heading, HeadingLevel, Table, HList
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.concrete.text import Line, Text
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.style import Font
|
||||
|
||||
|
||||
@dataclass
|
||||
class RenderingPosition:
|
||||
"""
|
||||
Complete state for resuming rendering at any point in a document.
|
||||
Position is tied to abstract document structure for stability across font changes.
|
||||
"""
|
||||
chapter_index: int = 0 # Which chapter (based on headings)
|
||||
block_index: int = 0 # Which block within chapter
|
||||
word_index: int = 0 # Which word within block (for paragraphs)
|
||||
table_row: int = 0 # Which row for tables
|
||||
table_col: int = 0 # Which column for tables
|
||||
list_item_index: int = 0 # Which item for lists
|
||||
remaining_pretext: Optional[str] = None # Hyphenated word continuation
|
||||
page_y_offset: int = 0 # Vertical position on page
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize position for saving to file/database"""
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'RenderingPosition':
|
||||
"""Deserialize position from saved state"""
|
||||
return cls(**data)
|
||||
|
||||
def copy(self) -> 'RenderingPosition':
|
||||
"""Create a copy of this position"""
|
||||
return RenderingPosition(**asdict(self))
|
||||
|
||||
def __eq__(self, other) -> bool:
|
||||
"""Check if two positions are equal"""
|
||||
if not isinstance(other, RenderingPosition):
|
||||
return False
|
||||
return asdict(self) == asdict(other)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
"""Make position hashable for use as dict key"""
|
||||
return hash(tuple(asdict(self).values()))
|
||||
|
||||
|
||||
class ChapterInfo:
|
||||
"""Information about a chapter/section in the document"""
|
||||
|
||||
def __init__(self, title: str, level: HeadingLevel, position: RenderingPosition, block_index: int):
|
||||
self.title = title
|
||||
self.level = level
|
||||
self.position = position
|
||||
self.block_index = block_index
|
||||
|
||||
|
||||
class ChapterNavigator:
|
||||
"""
|
||||
Handles chapter/section navigation based on HTML heading structure (H1-H6).
|
||||
Builds a table of contents and provides navigation capabilities.
|
||||
"""
|
||||
|
||||
def __init__(self, blocks: List[Block]):
|
||||
self.blocks = blocks
|
||||
self.chapters: List[ChapterInfo] = []
|
||||
self._build_chapter_map()
|
||||
|
||||
def _build_chapter_map(self):
|
||||
"""Scan blocks for headings and build chapter navigation map"""
|
||||
current_chapter_index = 0
|
||||
|
||||
for block_index, block in enumerate(self.blocks):
|
||||
if isinstance(block, Heading):
|
||||
# Create position for this heading
|
||||
position = RenderingPosition(
|
||||
chapter_index=current_chapter_index,
|
||||
block_index=0, # Heading is first block in its chapter
|
||||
word_index=0,
|
||||
table_row=0,
|
||||
table_col=0,
|
||||
list_item_index=0
|
||||
)
|
||||
|
||||
# Extract heading text
|
||||
heading_text = self._extract_heading_text(block)
|
||||
|
||||
chapter_info = ChapterInfo(
|
||||
title=heading_text,
|
||||
level=block.level,
|
||||
position=position,
|
||||
block_index=block_index
|
||||
)
|
||||
|
||||
self.chapters.append(chapter_info)
|
||||
|
||||
# Only increment chapter index for top-level headings (H1)
|
||||
if block.level == HeadingLevel.H1:
|
||||
current_chapter_index += 1
|
||||
|
||||
def _extract_heading_text(self, heading: Heading) -> str:
|
||||
"""Extract text content from a heading block"""
|
||||
words = []
|
||||
for word in heading.words():
|
||||
if isinstance(word, Word):
|
||||
words.append(word.text)
|
||||
return " ".join(words)
|
||||
|
||||
def get_table_of_contents(self) -> List[Tuple[str, HeadingLevel, RenderingPosition]]:
|
||||
"""Generate table of contents from heading structure"""
|
||||
return [(chapter.title, chapter.level, chapter.position) for chapter in self.chapters]
|
||||
|
||||
def get_chapter_position(self, chapter_title: str) -> Optional[RenderingPosition]:
|
||||
"""Get rendering position for a chapter by title"""
|
||||
for chapter in self.chapters:
|
||||
if chapter.title.lower() == chapter_title.lower():
|
||||
return chapter.position
|
||||
return None
|
||||
|
||||
def get_current_chapter(self, position: RenderingPosition) -> Optional[ChapterInfo]:
|
||||
"""Determine which chapter contains the current position"""
|
||||
if not self.chapters:
|
||||
return None
|
||||
|
||||
# Find the chapter that contains this position
|
||||
for i, chapter in enumerate(self.chapters):
|
||||
# Check if this is the last chapter or if position is before next chapter
|
||||
if i == len(self.chapters) - 1:
|
||||
return chapter
|
||||
|
||||
next_chapter = self.chapters[i + 1]
|
||||
if position.chapter_index < next_chapter.position.chapter_index:
|
||||
return chapter
|
||||
|
||||
return self.chapters[0] if self.chapters else None
|
||||
|
||||
|
||||
class FontScaler:
|
||||
"""
|
||||
Handles font scaling operations for ereader font size adjustments.
|
||||
Applies scaling at layout/render time while preserving original font objects.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def scale_font(font: Font, scale_factor: float) -> Font:
|
||||
"""
|
||||
Create a scaled version of a font for layout calculations.
|
||||
|
||||
Args:
|
||||
font: Original font object
|
||||
scale_factor: Scaling factor (1.0 = no change, 2.0 = double size, etc.)
|
||||
|
||||
Returns:
|
||||
New Font object with scaled size
|
||||
"""
|
||||
if scale_factor == 1.0:
|
||||
return font
|
||||
|
||||
scaled_size = max(1, int(font.font_size * scale_factor))
|
||||
|
||||
return Font(
|
||||
font_path=font._font_path,
|
||||
font_size=scaled_size,
|
||||
colour=font.colour,
|
||||
weight=font.weight,
|
||||
style=font.style,
|
||||
decoration=font.decoration,
|
||||
background=font.background,
|
||||
language=font.language,
|
||||
min_hyphenation_width=font.min_hyphenation_width
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def scale_word_spacing(spacing: Tuple[int, int], scale_factor: float) -> Tuple[int, int]:
|
||||
"""Scale word spacing constraints proportionally"""
|
||||
if scale_factor == 1.0:
|
||||
return spacing
|
||||
|
||||
min_spacing, max_spacing = spacing
|
||||
return (
|
||||
max(1, int(min_spacing * scale_factor)),
|
||||
max(2, int(max_spacing * scale_factor))
|
||||
)
|
||||
|
||||
|
||||
class BidirectionalLayouter:
|
||||
"""
|
||||
Core layout engine supporting both forward and backward page rendering.
|
||||
Handles font scaling and maintains position state.
|
||||
"""
|
||||
|
||||
def __init__(self, blocks: List[Block], page_style: PageStyle, page_size: Tuple[int, int] = (800, 600)):
|
||||
self.blocks = blocks
|
||||
self.page_style = page_style
|
||||
self.page_size = page_size
|
||||
self.chapter_navigator = ChapterNavigator(blocks)
|
||||
|
||||
def render_page_forward(self, position: RenderingPosition, font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
|
||||
"""
|
||||
Render a page starting from the given position, moving forward through the document.
|
||||
|
||||
Args:
|
||||
position: Starting position in document
|
||||
font_scale: Font scaling factor
|
||||
|
||||
Returns:
|
||||
Tuple of (rendered_page, next_position)
|
||||
"""
|
||||
page = Page(size=self.page_size, style=self.page_style)
|
||||
current_pos = position.copy()
|
||||
|
||||
# Start laying out blocks from the current position
|
||||
while current_pos.chapter_index < len(self.blocks) and page.free_space()[1] > 0:
|
||||
block = self.blocks[current_pos.block_index]
|
||||
|
||||
# Apply font scaling to the block
|
||||
scaled_block = self._scale_block_fonts(block, font_scale)
|
||||
|
||||
# Try to fit the block on the current page
|
||||
success, new_pos = self._layout_block_on_page(scaled_block, page, current_pos, font_scale)
|
||||
|
||||
if not success:
|
||||
# Block doesn't fit, we're done with this page
|
||||
break
|
||||
|
||||
current_pos = new_pos
|
||||
|
||||
return page, current_pos
|
||||
|
||||
def render_page_backward(self, end_position: RenderingPosition, font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
|
||||
"""
|
||||
Render a page that ends at the given position, filling backward.
|
||||
Critical for "previous page" navigation.
|
||||
|
||||
Args:
|
||||
end_position: Position where page should end
|
||||
font_scale: Font scaling factor
|
||||
|
||||
Returns:
|
||||
Tuple of (rendered_page, start_position)
|
||||
"""
|
||||
# This is a complex operation that requires iterative refinement
|
||||
# We'll start with an estimated start position and refine it
|
||||
|
||||
estimated_start = self._estimate_page_start(end_position, font_scale)
|
||||
|
||||
# Render forward from estimated start and see if we reach the target
|
||||
page, actual_end = self.render_page_forward(estimated_start, font_scale)
|
||||
|
||||
# If we overshot or undershot, adjust and try again
|
||||
# This is a simplified implementation - a full version would be more sophisticated
|
||||
if self._position_compare(actual_end, end_position) != 0:
|
||||
# Adjust estimate and try again (simplified)
|
||||
estimated_start = self._adjust_start_estimate(estimated_start, end_position, actual_end)
|
||||
page, actual_end = self.render_page_forward(estimated_start, font_scale)
|
||||
|
||||
return page, estimated_start
|
||||
|
||||
def _scale_block_fonts(self, block: Block, font_scale: float) -> Block:
|
||||
"""Apply font scaling to all fonts in a block"""
|
||||
if font_scale == 1.0:
|
||||
return block
|
||||
|
||||
# This is a simplified implementation
|
||||
# In practice, we'd need to handle each block type appropriately
|
||||
if isinstance(block, Paragraph):
|
||||
scaled_block = Paragraph(FontScaler.scale_font(block.style, font_scale))
|
||||
for word in block.words():
|
||||
if isinstance(word, Word):
|
||||
scaled_word = Word(word.text, FontScaler.scale_font(word.style, font_scale))
|
||||
scaled_block.add_word(scaled_word)
|
||||
return scaled_block
|
||||
|
||||
return block
|
||||
|
||||
def _layout_block_on_page(self, block: Block, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]:
|
||||
"""
|
||||
Try to layout a block on the page starting from the given position.
|
||||
|
||||
Returns:
|
||||
Tuple of (success, new_position)
|
||||
"""
|
||||
if isinstance(block, Paragraph):
|
||||
return self._layout_paragraph_on_page(block, page, position, font_scale)
|
||||
elif isinstance(block, Heading):
|
||||
return self._layout_heading_on_page(block, page, position, font_scale)
|
||||
elif isinstance(block, Table):
|
||||
return self._layout_table_on_page(block, page, position, font_scale)
|
||||
elif isinstance(block, HList):
|
||||
return self._layout_list_on_page(block, page, position, font_scale)
|
||||
else:
|
||||
# Skip unknown block types
|
||||
new_pos = position.copy()
|
||||
new_pos.block_index += 1
|
||||
return True, new_pos
|
||||
|
||||
def _layout_paragraph_on_page(self, paragraph: Paragraph, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]:
|
||||
"""Layout a paragraph on the page with font scaling support"""
|
||||
# This would integrate with the existing paragraph_layouter but with font scaling
|
||||
# For now, this is a placeholder implementation
|
||||
|
||||
# Calculate scaled line height
|
||||
line_height = int(paragraph.style.font_size * font_scale * 1.2) # 1.2 is line spacing factor
|
||||
|
||||
if not page.can_fit_line(line_height):
|
||||
return False, position
|
||||
|
||||
# Create a line and try to fit words
|
||||
y_cursor = page._current_y_offset
|
||||
x_cursor = page.border_size
|
||||
|
||||
# Scale word spacing constraints
|
||||
word_spacing = FontScaler.scale_word_spacing((5, 15), font_scale) # Default spacing
|
||||
|
||||
line = Line(
|
||||
spacing=word_spacing,
|
||||
origin=(x_cursor, y_cursor),
|
||||
size=(page.available_width, line_height),
|
||||
draw=page.draw,
|
||||
font=FontScaler.scale_font(paragraph.style, font_scale)
|
||||
)
|
||||
|
||||
# Add words starting from position.word_index
|
||||
words_added = 0
|
||||
for i, word in enumerate(paragraph.words[position.word_index:], start=position.word_index):
|
||||
success, overflow = line.add_word(word)
|
||||
if not success:
|
||||
break
|
||||
words_added += 1
|
||||
|
||||
if words_added > 0:
|
||||
page.add_child(line)
|
||||
page._current_y_offset += line_height
|
||||
|
||||
new_pos = position.copy()
|
||||
new_pos.word_index += words_added
|
||||
|
||||
# If we finished the paragraph, move to next block
|
||||
if new_pos.word_index >= len(paragraph.words):
|
||||
new_pos.block_index += 1
|
||||
new_pos.word_index = 0
|
||||
|
||||
return True, new_pos
|
||||
|
||||
return False, position
|
||||
|
||||
def _layout_heading_on_page(self, heading: Heading, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]:
|
||||
"""Layout a heading on the page"""
|
||||
# Similar to paragraph but with heading-specific styling
|
||||
return self._layout_paragraph_on_page(heading, page, position, font_scale)
|
||||
|
||||
def _layout_table_on_page(self, table: Table, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]:
|
||||
"""Layout a table on the page with column fitting and row continuation"""
|
||||
# This is a complex operation that would need full table layout logic
|
||||
# For now, skip tables
|
||||
new_pos = position.copy()
|
||||
new_pos.block_index += 1
|
||||
new_pos.table_row = 0
|
||||
new_pos.table_col = 0
|
||||
return True, new_pos
|
||||
|
||||
def _layout_list_on_page(self, hlist: HList, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]:
|
||||
"""Layout a list on the page"""
|
||||
# This would need list-specific layout logic
|
||||
# For now, skip lists
|
||||
new_pos = position.copy()
|
||||
new_pos.block_index += 1
|
||||
new_pos.list_item_index = 0
|
||||
return True, new_pos
|
||||
|
||||
def _estimate_page_start(self, end_position: RenderingPosition, font_scale: float) -> RenderingPosition:
|
||||
"""Estimate where a page should start to end at the given position"""
|
||||
# This is a simplified heuristic - a full implementation would be more sophisticated
|
||||
estimated_start = end_position.copy()
|
||||
|
||||
# Move back by an estimated number of blocks that would fit on a page
|
||||
estimated_blocks_per_page = max(1, int(10 / font_scale)) # Rough estimate
|
||||
estimated_start.block_index = max(0, end_position.block_index - estimated_blocks_per_page)
|
||||
estimated_start.word_index = 0
|
||||
|
||||
return estimated_start
|
||||
|
||||
def _adjust_start_estimate(self, current_start: RenderingPosition, target_end: RenderingPosition, actual_end: RenderingPosition) -> RenderingPosition:
|
||||
"""Adjust start position estimate based on overshoot/undershoot"""
|
||||
# Simplified adjustment logic
|
||||
adjusted = current_start.copy()
|
||||
|
||||
comparison = self._position_compare(actual_end, target_end)
|
||||
if comparison > 0: # Overshot
|
||||
adjusted.block_index = max(0, adjusted.block_index + 1)
|
||||
elif comparison < 0: # Undershot
|
||||
adjusted.block_index = max(0, adjusted.block_index - 1)
|
||||
|
||||
return adjusted
|
||||
|
||||
def _position_compare(self, pos1: RenderingPosition, pos2: RenderingPosition) -> int:
|
||||
"""Compare two positions (-1: pos1 < pos2, 0: equal, 1: pos1 > pos2)"""
|
||||
if pos1.chapter_index != pos2.chapter_index:
|
||||
return 1 if pos1.chapter_index > pos2.chapter_index else -1
|
||||
if pos1.block_index != pos2.block_index:
|
||||
return 1 if pos1.block_index > pos2.block_index else -1
|
||||
if pos1.word_index != pos2.word_index:
|
||||
return 1 if pos1.word_index > pos2.word_index else -1
|
||||
return 0
|
||||
|
||||
|
||||
# Add can_fit_line method to Page class if it doesn't exist
|
||||
def _add_page_methods():
|
||||
"""Add missing methods to Page class"""
|
||||
if not hasattr(Page, 'can_fit_line'):
|
||||
def can_fit_line(self, line_height: int) -> bool:
|
||||
"""Check if a line of given height can fit on the page"""
|
||||
available_height = self.content_size[1] - self._current_y_offset
|
||||
return available_height >= line_height
|
||||
|
||||
Page.can_fit_line = can_fit_line
|
||||
|
||||
if not hasattr(Page, 'available_width'):
|
||||
@property
|
||||
def available_width(self) -> int:
|
||||
"""Get available width for content"""
|
||||
return self.content_size[0]
|
||||
|
||||
Page.available_width = available_width
|
||||
|
||||
# Apply the page methods
|
||||
_add_page_methods()
|
||||
@@ -0,0 +1,493 @@
|
||||
"""
|
||||
High-performance ereader layout manager with sub-second page rendering.
|
||||
|
||||
This module provides the main interface for ereader applications, combining
|
||||
position tracking, font scaling, chapter navigation, and intelligent page buffering
|
||||
into a unified, easy-to-use API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import List, Dict, Optional, Tuple, Any, Callable
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from .ereader_layout import RenderingPosition, ChapterNavigator, ChapterInfo
|
||||
from .page_buffer import BufferedPageRenderer
|
||||
from pyWebLayout.abstract.block import Block, HeadingLevel
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
|
||||
|
||||
class BookmarkManager:
|
||||
"""
|
||||
Manages bookmarks and reading position persistence for ereader applications.
|
||||
"""
|
||||
|
||||
def __init__(self, document_id: str, bookmarks_dir: str = "bookmarks"):
|
||||
"""
|
||||
Initialize bookmark manager.
|
||||
|
||||
Args:
|
||||
document_id: Unique identifier for the document
|
||||
bookmarks_dir: Directory to store bookmark files
|
||||
"""
|
||||
self.document_id = document_id
|
||||
self.bookmarks_dir = Path(bookmarks_dir)
|
||||
self.bookmarks_dir.mkdir(exist_ok=True)
|
||||
|
||||
self.bookmarks_file = self.bookmarks_dir / f"{document_id}_bookmarks.json"
|
||||
self.position_file = self.bookmarks_dir / f"{document_id}_position.json"
|
||||
|
||||
self._bookmarks: Dict[str, RenderingPosition] = {}
|
||||
self._load_bookmarks()
|
||||
|
||||
def _load_bookmarks(self):
|
||||
"""Load bookmarks from file"""
|
||||
if self.bookmarks_file.exists():
|
||||
try:
|
||||
with open(self.bookmarks_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
self._bookmarks = {
|
||||
name: RenderingPosition.from_dict(pos_data)
|
||||
for name, pos_data in data.items()
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Failed to load bookmarks: {e}")
|
||||
self._bookmarks = {}
|
||||
|
||||
def _save_bookmarks(self):
|
||||
"""Save bookmarks to file"""
|
||||
try:
|
||||
data = {
|
||||
name: position.to_dict()
|
||||
for name, position in self._bookmarks.items()
|
||||
}
|
||||
with open(self.bookmarks_file, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
except Exception as e:
|
||||
print(f"Failed to save bookmarks: {e}")
|
||||
|
||||
def add_bookmark(self, name: str, position: RenderingPosition):
|
||||
"""
|
||||
Add a bookmark at the given position.
|
||||
|
||||
Args:
|
||||
name: Bookmark name
|
||||
position: Position to bookmark
|
||||
"""
|
||||
self._bookmarks[name] = position
|
||||
self._save_bookmarks()
|
||||
|
||||
def remove_bookmark(self, name: str) -> bool:
|
||||
"""
|
||||
Remove a bookmark.
|
||||
|
||||
Args:
|
||||
name: Bookmark name to remove
|
||||
|
||||
Returns:
|
||||
True if bookmark was removed, False if not found
|
||||
"""
|
||||
if name in self._bookmarks:
|
||||
del self._bookmarks[name]
|
||||
self._save_bookmarks()
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_bookmark(self, name: str) -> Optional[RenderingPosition]:
|
||||
"""
|
||||
Get a bookmark position.
|
||||
|
||||
Args:
|
||||
name: Bookmark name
|
||||
|
||||
Returns:
|
||||
Bookmark position or None if not found
|
||||
"""
|
||||
return self._bookmarks.get(name)
|
||||
|
||||
def list_bookmarks(self) -> List[Tuple[str, RenderingPosition]]:
|
||||
"""
|
||||
Get all bookmarks.
|
||||
|
||||
Returns:
|
||||
List of (name, position) tuples
|
||||
"""
|
||||
return list(self._bookmarks.items())
|
||||
|
||||
def save_reading_position(self, position: RenderingPosition):
|
||||
"""
|
||||
Save the current reading position.
|
||||
|
||||
Args:
|
||||
position: Current reading position
|
||||
"""
|
||||
try:
|
||||
with open(self.position_file, 'w') as f:
|
||||
json.dump(position.to_dict(), f, indent=2)
|
||||
except Exception as e:
|
||||
print(f"Failed to save reading position: {e}")
|
||||
|
||||
def load_reading_position(self) -> Optional[RenderingPosition]:
|
||||
"""
|
||||
Load the last reading position.
|
||||
|
||||
Returns:
|
||||
Last reading position or None if not found
|
||||
"""
|
||||
if self.position_file.exists():
|
||||
try:
|
||||
with open(self.position_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
return RenderingPosition.from_dict(data)
|
||||
except Exception as e:
|
||||
print(f"Failed to load reading position: {e}")
|
||||
return None
|
||||
|
||||
|
||||
class EreaderLayoutManager:
|
||||
"""
|
||||
High-level ereader layout manager providing a complete interface for ereader applications.
|
||||
|
||||
Features:
|
||||
- Sub-second page rendering with intelligent buffering
|
||||
- Font scaling support
|
||||
- Chapter navigation
|
||||
- Bookmark management
|
||||
- Position persistence
|
||||
- Progress tracking
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
blocks: List[Block],
|
||||
page_size: Tuple[int, int],
|
||||
document_id: str = "default",
|
||||
buffer_size: int = 5,
|
||||
page_style: Optional[PageStyle] = None):
|
||||
"""
|
||||
Initialize the ereader layout manager.
|
||||
|
||||
Args:
|
||||
blocks: Document blocks to render
|
||||
page_size: Page size (width, height) in pixels
|
||||
document_id: Unique identifier for the document (for bookmarks/position)
|
||||
buffer_size: Number of pages to cache in each direction
|
||||
page_style: Custom page styling (uses default if None)
|
||||
"""
|
||||
self.blocks = blocks
|
||||
self.page_size = page_size
|
||||
self.document_id = document_id
|
||||
|
||||
# Initialize page style
|
||||
if page_style is None:
|
||||
page_style = PageStyle()
|
||||
self.page_style = page_style
|
||||
|
||||
# Initialize core components
|
||||
self.renderer = BufferedPageRenderer(blocks, page_style, buffer_size, page_size)
|
||||
self.chapter_navigator = ChapterNavigator(blocks)
|
||||
self.bookmark_manager = BookmarkManager(document_id)
|
||||
|
||||
# Current state
|
||||
self.current_position = RenderingPosition()
|
||||
self.font_scale = 1.0
|
||||
|
||||
# Load last reading position if available
|
||||
saved_position = self.bookmark_manager.load_reading_position()
|
||||
if saved_position:
|
||||
self.current_position = saved_position
|
||||
|
||||
# Callbacks for UI updates
|
||||
self.position_changed_callback: Optional[Callable[[RenderingPosition], None]] = None
|
||||
self.chapter_changed_callback: Optional[Callable[[Optional[ChapterInfo]], None]] = None
|
||||
|
||||
def set_position_changed_callback(self, callback: Callable[[RenderingPosition], None]):
|
||||
"""Set callback for position changes"""
|
||||
self.position_changed_callback = callback
|
||||
|
||||
def set_chapter_changed_callback(self, callback: Callable[[Optional[ChapterInfo]], None]):
|
||||
"""Set callback for chapter changes"""
|
||||
self.chapter_changed_callback = callback
|
||||
|
||||
def _notify_position_changed(self):
|
||||
"""Notify UI of position change"""
|
||||
if self.position_changed_callback:
|
||||
self.position_changed_callback(self.current_position)
|
||||
|
||||
# Check if chapter changed
|
||||
current_chapter = self.chapter_navigator.get_current_chapter(self.current_position)
|
||||
if self.chapter_changed_callback:
|
||||
self.chapter_changed_callback(current_chapter)
|
||||
|
||||
# Auto-save reading position
|
||||
self.bookmark_manager.save_reading_position(self.current_position)
|
||||
|
||||
def get_current_page(self) -> Page:
|
||||
"""
|
||||
Get the page at the current reading position.
|
||||
|
||||
Returns:
|
||||
Rendered page
|
||||
"""
|
||||
page, _ = self.renderer.render_page(self.current_position, self.font_scale)
|
||||
return page
|
||||
|
||||
def next_page(self) -> Optional[Page]:
|
||||
"""
|
||||
Advance to the next page.
|
||||
|
||||
Returns:
|
||||
Next page or None if at end of document
|
||||
"""
|
||||
page, next_position = self.renderer.render_page(self.current_position, self.font_scale)
|
||||
|
||||
# Check if we made progress
|
||||
if next_position != self.current_position:
|
||||
self.current_position = next_position
|
||||
self._notify_position_changed()
|
||||
return self.get_current_page()
|
||||
|
||||
return None # At end of document
|
||||
|
||||
def previous_page(self) -> Optional[Page]:
|
||||
"""
|
||||
Go to the previous page.
|
||||
|
||||
Returns:
|
||||
Previous page or None if at beginning of document
|
||||
"""
|
||||
if self._is_at_beginning():
|
||||
return None
|
||||
|
||||
# Use backward rendering to find the previous page
|
||||
page, start_position = self.renderer.render_page_backward(self.current_position, self.font_scale)
|
||||
|
||||
if start_position != self.current_position:
|
||||
self.current_position = start_position
|
||||
self._notify_position_changed()
|
||||
return page
|
||||
|
||||
return None # At beginning of document
|
||||
|
||||
def _is_at_beginning(self) -> bool:
|
||||
"""Check if we're at the beginning of the document"""
|
||||
return (self.current_position.chapter_index == 0 and
|
||||
self.current_position.block_index == 0 and
|
||||
self.current_position.word_index == 0)
|
||||
|
||||
def jump_to_position(self, position: RenderingPosition) -> Page:
|
||||
"""
|
||||
Jump to a specific position in the document.
|
||||
|
||||
Args:
|
||||
position: Position to jump to
|
||||
|
||||
Returns:
|
||||
Page at the new position
|
||||
"""
|
||||
self.current_position = position
|
||||
self._notify_position_changed()
|
||||
return self.get_current_page()
|
||||
|
||||
def jump_to_chapter(self, chapter_title: str) -> Optional[Page]:
|
||||
"""
|
||||
Jump to a specific chapter by title.
|
||||
|
||||
Args:
|
||||
chapter_title: Title of the chapter to jump to
|
||||
|
||||
Returns:
|
||||
Page at chapter start or None if chapter not found
|
||||
"""
|
||||
position = self.chapter_navigator.get_chapter_position(chapter_title)
|
||||
if position:
|
||||
return self.jump_to_position(position)
|
||||
return None
|
||||
|
||||
def jump_to_chapter_index(self, chapter_index: int) -> Optional[Page]:
|
||||
"""
|
||||
Jump to a chapter by index.
|
||||
|
||||
Args:
|
||||
chapter_index: Index of the chapter (0-based)
|
||||
|
||||
Returns:
|
||||
Page at chapter start or None if index invalid
|
||||
"""
|
||||
chapters = self.chapter_navigator.chapters
|
||||
if 0 <= chapter_index < len(chapters):
|
||||
return self.jump_to_position(chapters[chapter_index].position)
|
||||
return None
|
||||
|
||||
def set_font_scale(self, scale: float) -> Page:
|
||||
"""
|
||||
Change the font scale and re-render current page.
|
||||
|
||||
Args:
|
||||
scale: Font scaling factor (1.0 = normal, 2.0 = double size, etc.)
|
||||
|
||||
Returns:
|
||||
Re-rendered page with new font scale
|
||||
"""
|
||||
if scale != self.font_scale:
|
||||
self.font_scale = scale
|
||||
# The renderer will handle cache invalidation
|
||||
|
||||
return self.get_current_page()
|
||||
|
||||
def get_font_scale(self) -> float:
|
||||
"""Get the current font scale"""
|
||||
return self.font_scale
|
||||
|
||||
def get_table_of_contents(self) -> List[Tuple[str, HeadingLevel, RenderingPosition]]:
|
||||
"""
|
||||
Get the table of contents.
|
||||
|
||||
Returns:
|
||||
List of (title, level, position) tuples
|
||||
"""
|
||||
return self.chapter_navigator.get_table_of_contents()
|
||||
|
||||
def get_current_chapter(self) -> Optional[ChapterInfo]:
|
||||
"""
|
||||
Get information about the current chapter.
|
||||
|
||||
Returns:
|
||||
Current chapter info or None if no chapters
|
||||
"""
|
||||
return self.chapter_navigator.get_current_chapter(self.current_position)
|
||||
|
||||
def add_bookmark(self, name: str) -> bool:
|
||||
"""
|
||||
Add a bookmark at the current position.
|
||||
|
||||
Args:
|
||||
name: Bookmark name
|
||||
|
||||
Returns:
|
||||
True if bookmark was added successfully
|
||||
"""
|
||||
try:
|
||||
self.bookmark_manager.add_bookmark(name, self.current_position)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def remove_bookmark(self, name: str) -> bool:
|
||||
"""
|
||||
Remove a bookmark.
|
||||
|
||||
Args:
|
||||
name: Bookmark name
|
||||
|
||||
Returns:
|
||||
True if bookmark was removed
|
||||
"""
|
||||
return self.bookmark_manager.remove_bookmark(name)
|
||||
|
||||
def jump_to_bookmark(self, name: str) -> Optional[Page]:
|
||||
"""
|
||||
Jump to a bookmark.
|
||||
|
||||
Args:
|
||||
name: Bookmark name
|
||||
|
||||
Returns:
|
||||
Page at bookmark position or None if bookmark not found
|
||||
"""
|
||||
position = self.bookmark_manager.get_bookmark(name)
|
||||
if position:
|
||||
return self.jump_to_position(position)
|
||||
return None
|
||||
|
||||
def list_bookmarks(self) -> List[Tuple[str, RenderingPosition]]:
|
||||
"""
|
||||
Get all bookmarks.
|
||||
|
||||
Returns:
|
||||
List of (name, position) tuples
|
||||
"""
|
||||
return self.bookmark_manager.list_bookmarks()
|
||||
|
||||
def get_reading_progress(self) -> float:
|
||||
"""
|
||||
Get reading progress as a percentage.
|
||||
|
||||
Returns:
|
||||
Progress from 0.0 to 1.0
|
||||
"""
|
||||
if not self.blocks:
|
||||
return 0.0
|
||||
|
||||
# Simple progress calculation based on block index
|
||||
# A more sophisticated version would consider word positions
|
||||
total_blocks = len(self.blocks)
|
||||
current_block = min(self.current_position.block_index, total_blocks - 1)
|
||||
|
||||
return current_block / max(1, total_blocks - 1)
|
||||
|
||||
def get_position_info(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get detailed information about the current position.
|
||||
|
||||
Returns:
|
||||
Dictionary with position details
|
||||
"""
|
||||
current_chapter = self.get_current_chapter()
|
||||
|
||||
return {
|
||||
'position': self.current_position.to_dict(),
|
||||
'chapter': {
|
||||
'title': current_chapter.title if current_chapter else None,
|
||||
'level': current_chapter.level if current_chapter else None,
|
||||
'index': current_chapter.block_index if current_chapter else None
|
||||
},
|
||||
'progress': self.get_reading_progress(),
|
||||
'font_scale': self.font_scale,
|
||||
'page_size': self.page_size
|
||||
}
|
||||
|
||||
def get_cache_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get cache statistics for debugging/monitoring.
|
||||
|
||||
Returns:
|
||||
Dictionary with cache statistics
|
||||
"""
|
||||
return self.renderer.get_cache_stats()
|
||||
|
||||
def shutdown(self):
|
||||
"""
|
||||
Shutdown the ereader manager and clean up resources.
|
||||
Call this when the application is closing.
|
||||
"""
|
||||
# Save current position
|
||||
self.bookmark_manager.save_reading_position(self.current_position)
|
||||
|
||||
# Shutdown renderer and buffer
|
||||
self.renderer.shutdown()
|
||||
|
||||
def __del__(self):
|
||||
"""Cleanup on destruction"""
|
||||
self.shutdown()
|
||||
|
||||
|
||||
# Convenience function for quick setup
|
||||
def create_ereader_manager(blocks: List[Block],
|
||||
page_size: Tuple[int, int],
|
||||
document_id: str = "default",
|
||||
**kwargs) -> EreaderLayoutManager:
|
||||
"""
|
||||
Convenience function to create an ereader manager with sensible defaults.
|
||||
|
||||
Args:
|
||||
blocks: Document blocks to render
|
||||
page_size: Page size (width, height) in pixels
|
||||
document_id: Unique identifier for the document
|
||||
**kwargs: Additional arguments passed to EreaderLayoutManager
|
||||
|
||||
Returns:
|
||||
Configured EreaderLayoutManager instance
|
||||
"""
|
||||
return EreaderLayoutManager(blocks, page_size, document_id, **kwargs)
|
||||
@@ -0,0 +1,411 @@
|
||||
"""
|
||||
Multi-process page buffering system for high-performance ereader navigation.
|
||||
|
||||
This module provides intelligent page caching with background rendering using
|
||||
multiprocessing to achieve sub-second page navigation performance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import Dict, Optional, List, Tuple, Any
|
||||
from collections import OrderedDict
|
||||
import multiprocessing
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed, Future
|
||||
import threading
|
||||
import time
|
||||
import pickle
|
||||
from dataclasses import asdict
|
||||
|
||||
from .ereader_layout import RenderingPosition, BidirectionalLayouter
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.abstract.block import Block
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
|
||||
|
||||
def _render_page_worker(args: Tuple[List[Block], PageStyle, RenderingPosition, float, bool]) -> Tuple[RenderingPosition, bytes, RenderingPosition]:
|
||||
"""
|
||||
Worker function for multiprocess page rendering.
|
||||
|
||||
Args:
|
||||
args: Tuple of (blocks, page_style, position, font_scale, is_backward)
|
||||
|
||||
Returns:
|
||||
Tuple of (original_position, pickled_page, next_position)
|
||||
"""
|
||||
blocks, page_style, position, font_scale, is_backward = args
|
||||
|
||||
layouter = BidirectionalLayouter(blocks, page_style)
|
||||
|
||||
if is_backward:
|
||||
page, next_pos = layouter.render_page_backward(position, font_scale)
|
||||
else:
|
||||
page, next_pos = layouter.render_page_forward(position, font_scale)
|
||||
|
||||
# Serialize the page for inter-process communication
|
||||
pickled_page = pickle.dumps(page)
|
||||
|
||||
return position, pickled_page, next_pos
|
||||
|
||||
|
||||
class PageBuffer:
|
||||
"""
|
||||
Intelligent page caching system with LRU eviction and background rendering.
|
||||
Maintains separate forward and backward buffers for optimal navigation performance.
|
||||
"""
|
||||
|
||||
def __init__(self, buffer_size: int = 5, max_workers: int = 4):
|
||||
"""
|
||||
Initialize the page buffer.
|
||||
|
||||
Args:
|
||||
buffer_size: Number of pages to cache in each direction
|
||||
max_workers: Maximum number of worker processes for background rendering
|
||||
"""
|
||||
self.buffer_size = buffer_size
|
||||
self.max_workers = max_workers
|
||||
|
||||
# LRU caches for forward and backward pages
|
||||
self.forward_buffer: OrderedDict[RenderingPosition, Page] = OrderedDict()
|
||||
self.backward_buffer: OrderedDict[RenderingPosition, Page] = OrderedDict()
|
||||
|
||||
# Position tracking for next/previous positions
|
||||
self.position_map: Dict[RenderingPosition, RenderingPosition] = {} # current -> next
|
||||
self.reverse_position_map: Dict[RenderingPosition, RenderingPosition] = {} # current -> previous
|
||||
|
||||
# Background rendering
|
||||
self.executor: Optional[ProcessPoolExecutor] = None
|
||||
self.pending_renders: Dict[RenderingPosition, Future] = {}
|
||||
self.render_lock = threading.Lock()
|
||||
|
||||
# Document state
|
||||
self.blocks: Optional[List[Block]] = None
|
||||
self.page_style: Optional[PageStyle] = None
|
||||
self.current_font_scale: float = 1.0
|
||||
|
||||
def initialize(self, blocks: List[Block], page_style: PageStyle, font_scale: float = 1.0):
|
||||
"""
|
||||
Initialize the buffer with document blocks and page style.
|
||||
|
||||
Args:
|
||||
blocks: Document blocks to render
|
||||
page_style: Page styling configuration
|
||||
font_scale: Current font scaling factor
|
||||
"""
|
||||
self.blocks = blocks
|
||||
self.page_style = page_style
|
||||
self.current_font_scale = font_scale
|
||||
|
||||
# Start the process pool
|
||||
if self.executor is None:
|
||||
self.executor = ProcessPoolExecutor(max_workers=self.max_workers)
|
||||
|
||||
def get_page(self, position: RenderingPosition) -> Optional[Page]:
|
||||
"""
|
||||
Get a cached page if available.
|
||||
|
||||
Args:
|
||||
position: Position to get page for
|
||||
|
||||
Returns:
|
||||
Cached page or None if not available
|
||||
"""
|
||||
# Check forward buffer first
|
||||
if position in self.forward_buffer:
|
||||
# Move to end (most recently used)
|
||||
page = self.forward_buffer.pop(position)
|
||||
self.forward_buffer[position] = page
|
||||
return page
|
||||
|
||||
# Check backward buffer
|
||||
if position in self.backward_buffer:
|
||||
# Move to end (most recently used)
|
||||
page = self.backward_buffer.pop(position)
|
||||
self.backward_buffer[position] = page
|
||||
return page
|
||||
|
||||
return None
|
||||
|
||||
def cache_page(self, position: RenderingPosition, page: Page, next_position: Optional[RenderingPosition] = None, is_backward: bool = False):
|
||||
"""
|
||||
Cache a rendered page with LRU eviction.
|
||||
|
||||
Args:
|
||||
position: Position of the page
|
||||
page: Rendered page to cache
|
||||
next_position: Position of the next page (for forward navigation)
|
||||
is_backward: Whether this is a backward-rendered page
|
||||
"""
|
||||
target_buffer = self.backward_buffer if is_backward else self.forward_buffer
|
||||
|
||||
# Add to cache
|
||||
target_buffer[position] = page
|
||||
|
||||
# Track position relationships
|
||||
if next_position:
|
||||
if is_backward:
|
||||
self.reverse_position_map[next_position] = position
|
||||
else:
|
||||
self.position_map[position] = next_position
|
||||
|
||||
# Evict oldest if buffer is full
|
||||
if len(target_buffer) > self.buffer_size:
|
||||
oldest_pos, _ = target_buffer.popitem(last=False)
|
||||
# Clean up position maps
|
||||
self.position_map.pop(oldest_pos, None)
|
||||
self.reverse_position_map.pop(oldest_pos, None)
|
||||
|
||||
def start_background_rendering(self, current_position: RenderingPosition, direction: str = 'forward'):
|
||||
"""
|
||||
Start background rendering of upcoming pages.
|
||||
|
||||
Args:
|
||||
current_position: Current reading position
|
||||
direction: 'forward', 'backward', or 'both'
|
||||
"""
|
||||
if not self.blocks or not self.page_style or not self.executor:
|
||||
return
|
||||
|
||||
with self.render_lock:
|
||||
if direction in ['forward', 'both']:
|
||||
self._queue_forward_renders(current_position)
|
||||
|
||||
if direction in ['backward', 'both']:
|
||||
self._queue_backward_renders(current_position)
|
||||
|
||||
def _queue_forward_renders(self, start_position: RenderingPosition):
|
||||
"""Queue forward page renders starting from the given position"""
|
||||
current_pos = start_position
|
||||
|
||||
for i in range(self.buffer_size):
|
||||
# Skip if already cached or being rendered
|
||||
if current_pos in self.forward_buffer or current_pos in self.pending_renders:
|
||||
# Try to get next position from cache
|
||||
current_pos = self.position_map.get(current_pos)
|
||||
if not current_pos:
|
||||
break
|
||||
continue
|
||||
|
||||
# Queue render job
|
||||
args = (self.blocks, self.page_style, current_pos, self.current_font_scale, False)
|
||||
future = self.executor.submit(_render_page_worker, args)
|
||||
self.pending_renders[current_pos] = future
|
||||
|
||||
# We don't know the next position yet, so we'll update it when the render completes
|
||||
break
|
||||
|
||||
def _queue_backward_renders(self, start_position: RenderingPosition):
|
||||
"""Queue backward page renders ending at the given position"""
|
||||
current_pos = start_position
|
||||
|
||||
for i in range(self.buffer_size):
|
||||
# Skip if already cached or being rendered
|
||||
if current_pos in self.backward_buffer or current_pos in self.pending_renders:
|
||||
# Try to get previous position from cache
|
||||
current_pos = self.reverse_position_map.get(current_pos)
|
||||
if not current_pos:
|
||||
break
|
||||
continue
|
||||
|
||||
# Queue render job
|
||||
args = (self.blocks, self.page_style, current_pos, self.current_font_scale, True)
|
||||
future = self.executor.submit(_render_page_worker, args)
|
||||
self.pending_renders[current_pos] = future
|
||||
|
||||
# We don't know the previous position yet, so we'll update it when the render completes
|
||||
break
|
||||
|
||||
def check_completed_renders(self):
|
||||
"""Check for completed background renders and cache the results"""
|
||||
if not self.pending_renders:
|
||||
return
|
||||
|
||||
completed = []
|
||||
|
||||
with self.render_lock:
|
||||
for position, future in self.pending_renders.items():
|
||||
if future.done():
|
||||
try:
|
||||
original_pos, pickled_page, next_pos = future.result()
|
||||
|
||||
# Deserialize the page
|
||||
page = pickle.loads(pickled_page)
|
||||
|
||||
# Cache the page
|
||||
self.cache_page(original_pos, page, next_pos, is_backward=False)
|
||||
|
||||
completed.append(position)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Background render failed for position {position}: {e}")
|
||||
completed.append(position)
|
||||
|
||||
# Remove completed renders
|
||||
for pos in completed:
|
||||
self.pending_renders.pop(pos, None)
|
||||
|
||||
def invalidate_all(self):
|
||||
"""Clear all cached pages and cancel pending renders"""
|
||||
with self.render_lock:
|
||||
# Cancel pending renders
|
||||
for future in self.pending_renders.values():
|
||||
future.cancel()
|
||||
self.pending_renders.clear()
|
||||
|
||||
# Clear caches
|
||||
self.forward_buffer.clear()
|
||||
self.backward_buffer.clear()
|
||||
self.position_map.clear()
|
||||
self.reverse_position_map.clear()
|
||||
|
||||
def set_font_scale(self, font_scale: float):
|
||||
"""
|
||||
Update font scale and invalidate cache.
|
||||
|
||||
Args:
|
||||
font_scale: New font scaling factor
|
||||
"""
|
||||
if font_scale != self.current_font_scale:
|
||||
self.current_font_scale = font_scale
|
||||
self.invalidate_all()
|
||||
|
||||
def get_cache_stats(self) -> Dict[str, Any]:
|
||||
"""Get cache statistics for debugging/monitoring"""
|
||||
return {
|
||||
'forward_buffer_size': len(self.forward_buffer),
|
||||
'backward_buffer_size': len(self.backward_buffer),
|
||||
'pending_renders': len(self.pending_renders),
|
||||
'position_mappings': len(self.position_map),
|
||||
'reverse_position_mappings': len(self.reverse_position_map),
|
||||
'current_font_scale': self.current_font_scale
|
||||
}
|
||||
|
||||
def shutdown(self):
|
||||
"""Shutdown the page buffer and clean up resources"""
|
||||
if self.executor:
|
||||
# Cancel pending renders
|
||||
with self.render_lock:
|
||||
for future in self.pending_renders.values():
|
||||
future.cancel()
|
||||
|
||||
# Shutdown executor
|
||||
self.executor.shutdown(wait=True)
|
||||
self.executor = None
|
||||
|
||||
# Clear all caches
|
||||
self.invalidate_all()
|
||||
|
||||
def __del__(self):
|
||||
"""Cleanup on destruction"""
|
||||
self.shutdown()
|
||||
|
||||
|
||||
class BufferedPageRenderer:
|
||||
"""
|
||||
High-level interface for buffered page rendering with automatic background caching.
|
||||
"""
|
||||
|
||||
def __init__(self, blocks: List[Block], page_style: PageStyle, buffer_size: int = 5, page_size: Tuple[int, int] = (800, 600)):
|
||||
"""
|
||||
Initialize the buffered renderer.
|
||||
|
||||
Args:
|
||||
blocks: Document blocks to render
|
||||
page_style: Page styling configuration
|
||||
buffer_size: Number of pages to cache in each direction
|
||||
page_size: Page size (width, height) in pixels
|
||||
"""
|
||||
self.layouter = BidirectionalLayouter(blocks, page_style, page_size)
|
||||
self.buffer = PageBuffer(buffer_size)
|
||||
self.buffer.initialize(blocks, page_style)
|
||||
|
||||
self.current_position = RenderingPosition()
|
||||
self.font_scale = 1.0
|
||||
|
||||
def render_page(self, position: RenderingPosition, font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
|
||||
"""
|
||||
Render a page with intelligent caching.
|
||||
|
||||
Args:
|
||||
position: Position to render from
|
||||
font_scale: Font scaling factor
|
||||
|
||||
Returns:
|
||||
Tuple of (rendered_page, next_position)
|
||||
"""
|
||||
# Update font scale if changed
|
||||
if font_scale != self.font_scale:
|
||||
self.font_scale = font_scale
|
||||
self.buffer.set_font_scale(font_scale)
|
||||
|
||||
# Check cache first
|
||||
cached_page = self.buffer.get_page(position)
|
||||
if cached_page:
|
||||
# Get next position from position map
|
||||
next_pos = self.buffer.position_map.get(position, position)
|
||||
|
||||
# Start background rendering for upcoming pages
|
||||
self.buffer.start_background_rendering(position, 'forward')
|
||||
|
||||
return cached_page, next_pos
|
||||
|
||||
# Render the page directly
|
||||
page, next_pos = self.layouter.render_page_forward(position, font_scale)
|
||||
|
||||
# Cache the result
|
||||
self.buffer.cache_page(position, page, next_pos)
|
||||
|
||||
# Start background rendering
|
||||
self.buffer.start_background_rendering(position, 'both')
|
||||
|
||||
# Check for completed background renders
|
||||
self.buffer.check_completed_renders()
|
||||
|
||||
return page, next_pos
|
||||
|
||||
def render_page_backward(self, end_position: RenderingPosition, font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
|
||||
"""
|
||||
Render a page ending at the given position with intelligent caching.
|
||||
|
||||
Args:
|
||||
end_position: Position where page should end
|
||||
font_scale: Font scaling factor
|
||||
|
||||
Returns:
|
||||
Tuple of (rendered_page, start_position)
|
||||
"""
|
||||
# Update font scale if changed
|
||||
if font_scale != self.font_scale:
|
||||
self.font_scale = font_scale
|
||||
self.buffer.set_font_scale(font_scale)
|
||||
|
||||
# Check cache first
|
||||
cached_page = self.buffer.get_page(end_position)
|
||||
if cached_page:
|
||||
# Get previous position from reverse position map
|
||||
prev_pos = self.buffer.reverse_position_map.get(end_position, end_position)
|
||||
|
||||
# Start background rendering for previous pages
|
||||
self.buffer.start_background_rendering(end_position, 'backward')
|
||||
|
||||
return cached_page, prev_pos
|
||||
|
||||
# Render the page directly
|
||||
page, start_pos = self.layouter.render_page_backward(end_position, font_scale)
|
||||
|
||||
# Cache the result
|
||||
self.buffer.cache_page(start_pos, page, end_position, is_backward=True)
|
||||
|
||||
# Start background rendering
|
||||
self.buffer.start_background_rendering(end_position, 'both')
|
||||
|
||||
# Check for completed background renders
|
||||
self.buffer.check_completed_renders()
|
||||
|
||||
return page, start_pos
|
||||
|
||||
def get_cache_stats(self) -> Dict[str, Any]:
|
||||
"""Get cache statistics"""
|
||||
return self.buffer.get_cache_stats()
|
||||
|
||||
def shutdown(self):
|
||||
"""Shutdown the renderer and clean up resources"""
|
||||
self.buffer.shutdown()
|
||||
@@ -0,0 +1,481 @@
|
||||
"""
|
||||
Recursive location index system for dynamic content positioning.
|
||||
|
||||
This module provides a flexible, hierarchical position tracking system that can
|
||||
reference any type of content (words, images, table cells, list items, etc.)
|
||||
in a nested document structure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Dict, Any, Optional, Union, Tuple
|
||||
from enum import Enum
|
||||
import json
|
||||
import pickle
|
||||
import shelve
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ContentType(Enum):
|
||||
"""Types of content that can be referenced in the position index"""
|
||||
DOCUMENT = "document"
|
||||
CHAPTER = "chapter"
|
||||
BLOCK = "block"
|
||||
PARAGRAPH = "paragraph"
|
||||
HEADING = "heading"
|
||||
TABLE = "table"
|
||||
TABLE_ROW = "table_row"
|
||||
TABLE_CELL = "table_cell"
|
||||
LIST = "list"
|
||||
LIST_ITEM = "list_item"
|
||||
WORD = "word"
|
||||
IMAGE = "image"
|
||||
LINK = "link"
|
||||
BUTTON = "button"
|
||||
FORM_FIELD = "form_field"
|
||||
LINE = "line" # Rendered line of text
|
||||
PAGE = "page" # Rendered page
|
||||
|
||||
|
||||
@dataclass
|
||||
class LocationNode:
|
||||
"""
|
||||
A single node in the recursive location index.
|
||||
Each node represents a position within a specific content type.
|
||||
"""
|
||||
content_type: ContentType
|
||||
index: int = 0 # Position within this content type
|
||||
offset: int = 0 # Offset within the indexed item (e.g., character offset in word)
|
||||
metadata: Dict[str, Any] = field(default_factory=dict) # Additional context
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize node to dictionary"""
|
||||
return {
|
||||
'content_type': self.content_type.value,
|
||||
'index': self.index,
|
||||
'offset': self.offset,
|
||||
'metadata': self.metadata
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'LocationNode':
|
||||
"""Deserialize node from dictionary"""
|
||||
return cls(
|
||||
content_type=ContentType(data['content_type']),
|
||||
index=data['index'],
|
||||
offset=data['offset'],
|
||||
metadata=data.get('metadata', {})
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Human-readable representation"""
|
||||
if self.offset > 0:
|
||||
return f"{self.content_type.value}[{self.index}]+{self.offset}"
|
||||
return f"{self.content_type.value}[{self.index}]"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecursivePosition:
|
||||
"""
|
||||
Hierarchical position that can reference any nested content structure.
|
||||
|
||||
The path represents a traversal from document root to the specific location:
|
||||
- Document -> Chapter[2] -> Block[5] -> Paragraph -> Word[12] -> Character[3]
|
||||
- Document -> Chapter[1] -> Block[3] -> Table -> Row[2] -> Cell[1] -> Word[0]
|
||||
- Document -> Chapter[0] -> Block[1] -> Image
|
||||
"""
|
||||
path: List[LocationNode] = field(default_factory=list)
|
||||
rendering_metadata: Dict[str, Any] = field(default_factory=dict) # Font scale, page size, etc.
|
||||
|
||||
def __post_init__(self):
|
||||
"""Ensure we always have at least a document root"""
|
||||
if not self.path:
|
||||
self.path = [LocationNode(ContentType.DOCUMENT)]
|
||||
|
||||
def copy(self) -> 'RecursivePosition':
|
||||
"""Create a deep copy of this position"""
|
||||
return RecursivePosition(
|
||||
path=[LocationNode(node.content_type, node.index, node.offset, node.metadata.copy())
|
||||
for node in self.path],
|
||||
rendering_metadata=self.rendering_metadata.copy()
|
||||
)
|
||||
|
||||
def get_node(self, content_type: ContentType) -> Optional[LocationNode]:
|
||||
"""Get the first node of a specific content type in the path"""
|
||||
for node in self.path:
|
||||
if node.content_type == content_type:
|
||||
return node
|
||||
return None
|
||||
|
||||
def get_nodes(self, content_type: ContentType) -> List[LocationNode]:
|
||||
"""Get all nodes of a specific content type in the path"""
|
||||
return [node for node in self.path if node.content_type == content_type]
|
||||
|
||||
def add_node(self, node: LocationNode) -> 'RecursivePosition':
|
||||
"""Add a node to the path (returns self for chaining)"""
|
||||
self.path.append(node)
|
||||
return self
|
||||
|
||||
def pop_node(self) -> Optional[LocationNode]:
|
||||
"""Remove and return the last node in the path"""
|
||||
if len(self.path) > 1: # Keep at least document root
|
||||
return self.path.pop()
|
||||
return None
|
||||
|
||||
def get_depth(self) -> int:
|
||||
"""Get the depth of the position (number of nodes)"""
|
||||
return len(self.path)
|
||||
|
||||
def get_leaf_node(self) -> LocationNode:
|
||||
"""Get the deepest (most specific) node in the path"""
|
||||
return self.path[-1] if self.path else LocationNode(ContentType.DOCUMENT)
|
||||
|
||||
def truncate_to_type(self, content_type: ContentType) -> 'RecursivePosition':
|
||||
"""Truncate path to end at the first occurrence of the given content type"""
|
||||
for i, node in enumerate(self.path):
|
||||
if node.content_type == content_type:
|
||||
self.path = self.path[:i+1]
|
||||
break
|
||||
return self
|
||||
|
||||
def is_ancestor_of(self, other: 'RecursivePosition') -> bool:
|
||||
"""Check if this position is an ancestor of another position"""
|
||||
if len(self.path) >= len(other.path):
|
||||
return False
|
||||
|
||||
for i, node in enumerate(self.path):
|
||||
if i >= len(other.path):
|
||||
return False
|
||||
other_node = other.path[i]
|
||||
if (node.content_type != other_node.content_type or
|
||||
node.index != other_node.index):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def is_descendant_of(self, other: 'RecursivePosition') -> bool:
|
||||
"""Check if this position is a descendant of another position"""
|
||||
return other.is_ancestor_of(self)
|
||||
|
||||
def get_common_ancestor(self, other: 'RecursivePosition') -> 'RecursivePosition':
|
||||
"""Find the deepest common ancestor with another position"""
|
||||
common_path = []
|
||||
min_length = min(len(self.path), len(other.path))
|
||||
|
||||
for i in range(min_length):
|
||||
if (self.path[i].content_type == other.path[i].content_type and
|
||||
self.path[i].index == other.path[i].index):
|
||||
common_path.append(self.path[i])
|
||||
else:
|
||||
break
|
||||
|
||||
return RecursivePosition(path=common_path)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize position to dictionary for JSON storage"""
|
||||
return {
|
||||
'path': [node.to_dict() for node in self.path],
|
||||
'rendering_metadata': self.rendering_metadata
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'RecursivePosition':
|
||||
"""Deserialize position from dictionary"""
|
||||
return cls(
|
||||
path=[LocationNode.from_dict(node_data) for node_data in data['path']],
|
||||
rendering_metadata=data.get('rendering_metadata', {})
|
||||
)
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Serialize to JSON string"""
|
||||
return json.dumps(self.to_dict(), indent=2)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str) -> 'RecursivePosition':
|
||||
"""Deserialize from JSON string"""
|
||||
return cls.from_dict(json.loads(json_str))
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Human-readable path representation"""
|
||||
return " -> ".join(str(node) for node in self.path)
|
||||
|
||||
def __eq__(self, other) -> bool:
|
||||
"""Check equality with another position"""
|
||||
if not isinstance(other, RecursivePosition):
|
||||
return False
|
||||
return (self.path == other.path and
|
||||
self.rendering_metadata == other.rendering_metadata)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
"""Make position hashable for use as dict key"""
|
||||
path_tuple = tuple((node.content_type, node.index, node.offset) for node in self.path)
|
||||
return hash(path_tuple)
|
||||
|
||||
|
||||
class PositionBuilder:
|
||||
"""
|
||||
Builder class for constructing RecursivePosition objects fluently.
|
||||
|
||||
Example usage:
|
||||
position = (PositionBuilder()
|
||||
.chapter(2)
|
||||
.block(5)
|
||||
.paragraph()
|
||||
.word(12, offset=3)
|
||||
.build())
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._position = RecursivePosition()
|
||||
|
||||
def document(self, index: int = 0, **metadata) -> 'PositionBuilder':
|
||||
"""Add document node"""
|
||||
self._position.add_node(LocationNode(ContentType.DOCUMENT, index, metadata=metadata))
|
||||
return self
|
||||
|
||||
def chapter(self, index: int, **metadata) -> 'PositionBuilder':
|
||||
"""Add chapter node"""
|
||||
self._position.add_node(LocationNode(ContentType.CHAPTER, index, metadata=metadata))
|
||||
return self
|
||||
|
||||
def block(self, index: int, **metadata) -> 'PositionBuilder':
|
||||
"""Add block node"""
|
||||
self._position.add_node(LocationNode(ContentType.BLOCK, index, metadata=metadata))
|
||||
return self
|
||||
|
||||
def paragraph(self, index: int = 0, **metadata) -> 'PositionBuilder':
|
||||
"""Add paragraph node"""
|
||||
self._position.add_node(LocationNode(ContentType.PARAGRAPH, index, metadata=metadata))
|
||||
return self
|
||||
|
||||
def heading(self, index: int = 0, **metadata) -> 'PositionBuilder':
|
||||
"""Add heading node"""
|
||||
self._position.add_node(LocationNode(ContentType.HEADING, index, metadata=metadata))
|
||||
return self
|
||||
|
||||
def table(self, index: int = 0, **metadata) -> 'PositionBuilder':
|
||||
"""Add table node"""
|
||||
self._position.add_node(LocationNode(ContentType.TABLE, index, metadata=metadata))
|
||||
return self
|
||||
|
||||
def table_row(self, index: int, **metadata) -> 'PositionBuilder':
|
||||
"""Add table row node"""
|
||||
self._position.add_node(LocationNode(ContentType.TABLE_ROW, index, metadata=metadata))
|
||||
return self
|
||||
|
||||
def table_cell(self, index: int, **metadata) -> 'PositionBuilder':
|
||||
"""Add table cell node"""
|
||||
self._position.add_node(LocationNode(ContentType.TABLE_CELL, index, metadata=metadata))
|
||||
return self
|
||||
|
||||
def list(self, index: int = 0, **metadata) -> 'PositionBuilder':
|
||||
"""Add list node"""
|
||||
self._position.add_node(LocationNode(ContentType.LIST, index, metadata=metadata))
|
||||
return self
|
||||
|
||||
def list_item(self, index: int, **metadata) -> 'PositionBuilder':
|
||||
"""Add list item node"""
|
||||
self._position.add_node(LocationNode(ContentType.LIST_ITEM, index, metadata=metadata))
|
||||
return self
|
||||
|
||||
def word(self, index: int, offset: int = 0, **metadata) -> 'PositionBuilder':
|
||||
"""Add word node"""
|
||||
self._position.add_node(LocationNode(ContentType.WORD, index, offset, metadata=metadata))
|
||||
return self
|
||||
|
||||
def image(self, index: int = 0, **metadata) -> 'PositionBuilder':
|
||||
"""Add image node"""
|
||||
self._position.add_node(LocationNode(ContentType.IMAGE, index, metadata=metadata))
|
||||
return self
|
||||
|
||||
def link(self, index: int, **metadata) -> 'PositionBuilder':
|
||||
"""Add link node"""
|
||||
self._position.add_node(LocationNode(ContentType.LINK, index, metadata=metadata))
|
||||
return self
|
||||
|
||||
def button(self, index: int, **metadata) -> 'PositionBuilder':
|
||||
"""Add button node"""
|
||||
self._position.add_node(LocationNode(ContentType.BUTTON, index, metadata=metadata))
|
||||
return self
|
||||
|
||||
def form_field(self, index: int, **metadata) -> 'PositionBuilder':
|
||||
"""Add form field node"""
|
||||
self._position.add_node(LocationNode(ContentType.FORM_FIELD, index, metadata=metadata))
|
||||
return self
|
||||
|
||||
def line(self, index: int, **metadata) -> 'PositionBuilder':
|
||||
"""Add rendered line node"""
|
||||
self._position.add_node(LocationNode(ContentType.LINE, index, metadata=metadata))
|
||||
return self
|
||||
|
||||
def page(self, index: int, **metadata) -> 'PositionBuilder':
|
||||
"""Add page node"""
|
||||
self._position.add_node(LocationNode(ContentType.PAGE, index, metadata=metadata))
|
||||
return self
|
||||
|
||||
def with_rendering_metadata(self, **metadata) -> 'PositionBuilder':
|
||||
"""Add rendering metadata (font scale, page size, etc.)"""
|
||||
self._position.rendering_metadata.update(metadata)
|
||||
return self
|
||||
|
||||
def build(self) -> RecursivePosition:
|
||||
"""Build and return the final position"""
|
||||
return self._position
|
||||
|
||||
|
||||
class PositionStorage:
|
||||
"""
|
||||
Storage manager for recursive positions supporting both JSON and shelf formats.
|
||||
"""
|
||||
|
||||
def __init__(self, storage_dir: str = "positions", use_shelf: bool = False):
|
||||
"""
|
||||
Initialize position storage.
|
||||
|
||||
Args:
|
||||
storage_dir: Directory to store position files
|
||||
use_shelf: If True, use Python shelf format; if False, use JSON
|
||||
"""
|
||||
self.storage_dir = Path(storage_dir)
|
||||
self.storage_dir.mkdir(exist_ok=True)
|
||||
self.use_shelf = use_shelf
|
||||
|
||||
def save_position(self, document_id: str, position_name: str, position: RecursivePosition):
|
||||
"""Save a position to storage"""
|
||||
if self.use_shelf:
|
||||
self._save_to_shelf(document_id, position_name, position)
|
||||
else:
|
||||
self._save_to_json(document_id, position_name, position)
|
||||
|
||||
def load_position(self, document_id: str, position_name: str) -> Optional[RecursivePosition]:
|
||||
"""Load a position from storage"""
|
||||
if self.use_shelf:
|
||||
return self._load_from_shelf(document_id, position_name)
|
||||
else:
|
||||
return self._load_from_json(document_id, position_name)
|
||||
|
||||
def list_positions(self, document_id: str) -> List[str]:
|
||||
"""List all saved positions for a document"""
|
||||
if self.use_shelf:
|
||||
return self._list_shelf_positions(document_id)
|
||||
else:
|
||||
return self._list_json_positions(document_id)
|
||||
|
||||
def delete_position(self, document_id: str, position_name: str) -> bool:
|
||||
"""Delete a position from storage"""
|
||||
if self.use_shelf:
|
||||
return self._delete_from_shelf(document_id, position_name)
|
||||
else:
|
||||
return self._delete_from_json(document_id, position_name)
|
||||
|
||||
def _save_to_json(self, document_id: str, position_name: str, position: RecursivePosition):
|
||||
"""Save position as JSON file"""
|
||||
file_path = self.storage_dir / f"{document_id}_{position_name}.json"
|
||||
with open(file_path, 'w') as f:
|
||||
json.dump(position.to_dict(), f, indent=2)
|
||||
|
||||
def _load_from_json(self, document_id: str, position_name: str) -> Optional[RecursivePosition]:
|
||||
"""Load position from JSON file"""
|
||||
file_path = self.storage_dir / f"{document_id}_{position_name}.json"
|
||||
if not file_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(file_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
return RecursivePosition.from_dict(data)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _list_json_positions(self, document_id: str) -> List[str]:
|
||||
"""List JSON position files for a document"""
|
||||
pattern = f"{document_id}_*.json"
|
||||
files = list(self.storage_dir.glob(pattern))
|
||||
return [f.stem.replace(f"{document_id}_", "") for f in files]
|
||||
|
||||
def _delete_from_json(self, document_id: str, position_name: str) -> bool:
|
||||
"""Delete JSON position file"""
|
||||
file_path = self.storage_dir / f"{document_id}_{position_name}.json"
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _save_to_shelf(self, document_id: str, position_name: str, position: RecursivePosition):
|
||||
"""Save position to shelf database"""
|
||||
shelf_path = str(self.storage_dir / f"{document_id}.shelf")
|
||||
with shelve.open(shelf_path) as shelf:
|
||||
shelf[position_name] = position
|
||||
|
||||
def _load_from_shelf(self, document_id: str, position_name: str) -> Optional[RecursivePosition]:
|
||||
"""Load position from shelf database"""
|
||||
shelf_path = str(self.storage_dir / f"{document_id}.shelf")
|
||||
try:
|
||||
with shelve.open(shelf_path) as shelf:
|
||||
return shelf.get(position_name)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _list_shelf_positions(self, document_id: str) -> List[str]:
|
||||
"""List positions in shelf database"""
|
||||
shelf_path = str(self.storage_dir / f"{document_id}.shelf")
|
||||
try:
|
||||
with shelve.open(shelf_path) as shelf:
|
||||
return list(shelf.keys())
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def _delete_from_shelf(self, document_id: str, position_name: str) -> bool:
|
||||
"""Delete position from shelf database"""
|
||||
shelf_path = str(self.storage_dir / f"{document_id}.shelf")
|
||||
try:
|
||||
with shelve.open(shelf_path) as shelf:
|
||||
if position_name in shelf:
|
||||
del shelf[position_name]
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
# Convenience functions for common position patterns
|
||||
def create_word_position(chapter: int, block: int, word: int, char_offset: int = 0) -> RecursivePosition:
|
||||
"""Create a position pointing to a specific word and character"""
|
||||
return (PositionBuilder()
|
||||
.chapter(chapter)
|
||||
.block(block)
|
||||
.paragraph()
|
||||
.word(word, offset=char_offset)
|
||||
.build())
|
||||
|
||||
|
||||
def create_image_position(chapter: int, block: int, image_index: int = 0) -> RecursivePosition:
|
||||
"""Create a position pointing to an image"""
|
||||
return (PositionBuilder()
|
||||
.chapter(chapter)
|
||||
.block(block)
|
||||
.image(image_index)
|
||||
.build())
|
||||
|
||||
|
||||
def create_table_cell_position(chapter: int, block: int, row: int, col: int, word: int = 0) -> RecursivePosition:
|
||||
"""Create a position pointing to content in a table cell"""
|
||||
return (PositionBuilder()
|
||||
.chapter(chapter)
|
||||
.block(block)
|
||||
.table()
|
||||
.table_row(row)
|
||||
.table_cell(col)
|
||||
.word(word)
|
||||
.build())
|
||||
|
||||
|
||||
def create_list_item_position(chapter: int, block: int, item: int, word: int = 0) -> RecursivePosition:
|
||||
"""Create a position pointing to content in a list item"""
|
||||
return (PositionBuilder()
|
||||
.chapter(chapter)
|
||||
.block(block)
|
||||
.list()
|
||||
.list_item(item)
|
||||
.word(word)
|
||||
.build())
|
||||
@@ -1,28 +1,20 @@
|
||||
"""
|
||||
Styling module for the pyWebLayout library.
|
||||
Style system for the pyWebLayout library.
|
||||
|
||||
This package contains styling-related components including:
|
||||
- Font handling and text styling
|
||||
- Color management
|
||||
- Text decoration and formatting
|
||||
- Alignment and positioning properties
|
||||
This module provides the core styling components used throughout the library.
|
||||
"""
|
||||
|
||||
# Import alignment options
|
||||
from pyWebLayout.style.layout import Alignment
|
||||
|
||||
# Import font-related classes
|
||||
from pyWebLayout.style.fonts import (
|
||||
Font, FontWeight, FontStyle, TextDecoration
|
||||
from enum import Enum
|
||||
from .fonts import Font, FontWeight, FontStyle, TextDecoration
|
||||
from .abstract_style import (
|
||||
AbstractStyle, AbstractStyleRegistry, FontFamily, FontSize
|
||||
)
|
||||
from .concrete_style import ConcreteStyle
|
||||
from .page_style import PageStyle
|
||||
from .alignment import Alignment
|
||||
|
||||
# Import new style system
|
||||
from pyWebLayout.style.abstract_style import (
|
||||
AbstractStyle, AbstractStyleRegistry, FontFamily, FontSize, TextAlign
|
||||
)
|
||||
from pyWebLayout.style.concrete_style import (
|
||||
ConcreteStyle, ConcreteStyleRegistry, RenderingContext, StyleResolver
|
||||
)
|
||||
|
||||
# Import page styling
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
__all__ = [
|
||||
"Font", "FontWeight", "FontStyle", "TextDecoration",
|
||||
"AbstractStyle", "AbstractStyleRegistry", "FontFamily", "FontSize", "TextAlign",
|
||||
"ConcreteStyle", "PageStyle", "Alignment"
|
||||
]
|
||||
|
||||
@@ -49,12 +49,11 @@ class FontSize(Enum):
|
||||
return cls.MEDIUM
|
||||
|
||||
|
||||
class TextAlign(Enum):
|
||||
"""Text alignment options"""
|
||||
LEFT = "left"
|
||||
CENTER = "center"
|
||||
RIGHT = "right"
|
||||
JUSTIFY = "justify"
|
||||
# Import Alignment from the centralized location
|
||||
from .alignment import Alignment
|
||||
|
||||
# Use Alignment for text alignment
|
||||
TextAlign = Alignment
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Alignment options for the pyWebLayout library.
|
||||
|
||||
This module provides alignment-related functionality.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
class Alignment(Enum):
|
||||
"""Text alignment options"""
|
||||
LEFT = "left"
|
||||
RIGHT = "right"
|
||||
CENTER = "center"
|
||||
JUSTIFY = "justify"
|
||||
|
||||
def __str__(self):
|
||||
"""Return the string value of the alignment."""
|
||||
return self.value
|
||||
@@ -7,7 +7,8 @@ user preferences, device capabilities, and rendering context.
|
||||
|
||||
from typing import Dict, Optional, Tuple, Union, Any
|
||||
from dataclasses import dataclass
|
||||
from .abstract_style import AbstractStyle, FontFamily, FontSize, TextAlign
|
||||
from .abstract_style import AbstractStyle, FontFamily, FontSize
|
||||
from pyWebLayout.style.alignment import Alignment as TextAlign
|
||||
from .fonts import Font, FontWeight, FontStyle, TextDecoration
|
||||
import os
|
||||
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
"""
|
||||
Layout and alignment options for the pyWebLayout library.
|
||||
Layout options for the pyWebLayout library.
|
||||
|
||||
This module provides layout-related functionality.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class Alignment(Enum):
|
||||
"""
|
||||
Enum for alignment options used in layout and rendering.
|
||||
"""
|
||||
LEFT = 1
|
||||
CENTER = 2
|
||||
RIGHT = 3
|
||||
TOP = 4
|
||||
BOTTOM = 5
|
||||
JUSTIFY = 6
|
||||
|
||||
@@ -1,53 +1,54 @@
|
||||
from typing import Tuple, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .abstract_style import AbstractStyle, FontFamily, FontSize
|
||||
from pyWebLayout.style.alignment import Alignment as TextAlign
|
||||
|
||||
@dataclass
|
||||
class PageStyle:
|
||||
"""
|
||||
Defines the styling properties for a page including borders, spacing, and layout.
|
||||
"""
|
||||
|
||||
|
||||
# Border properties
|
||||
border_width: int = 0
|
||||
border_color: Tuple[int, int, int] = (0, 0, 0)
|
||||
|
||||
|
||||
# Spacing properties
|
||||
line_spacing: int = 5
|
||||
inter_block_spacing: int = 15
|
||||
|
||||
|
||||
# Padding (top, right, bottom, left)
|
||||
padding: Tuple[int, int, int, int] = (20, 20, 20, 20)
|
||||
|
||||
|
||||
# Background color
|
||||
background_color: Tuple[int, int, int] = (255, 255, 255)
|
||||
|
||||
|
||||
@property
|
||||
def padding_top(self) -> int:
|
||||
return self.padding[0]
|
||||
|
||||
|
||||
@property
|
||||
def padding_right(self) -> int:
|
||||
return self.padding[1]
|
||||
|
||||
|
||||
@property
|
||||
def padding_bottom(self) -> int:
|
||||
return self.padding[2]
|
||||
|
||||
|
||||
@property
|
||||
def padding_left(self) -> int:
|
||||
return self.padding[3]
|
||||
|
||||
|
||||
@property
|
||||
def total_horizontal_padding(self) -> int:
|
||||
"""Get total horizontal padding (left + right)"""
|
||||
return self.padding_left + self.padding_right
|
||||
|
||||
|
||||
@property
|
||||
def total_vertical_padding(self) -> int:
|
||||
"""Get total vertical padding (top + bottom)"""
|
||||
return self.padding_top + self.padding_bottom
|
||||
|
||||
|
||||
@property
|
||||
def total_border_width(self) -> int:
|
||||
"""Get total border width (both sides)"""
|
||||
|
||||
Reference in New Issue
Block a user