457 lines
16 KiB
Python
457 lines
16 KiB
Python
"""
|
|
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
|
|
from concurrent.futures import ProcessPoolExecutor, Future
|
|
import threading
|
|
import pickle
|
|
|
|
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)
|
|
|
|
# Only use cache if we have the reverse position mapping
|
|
# Otherwise, we need to compute it
|
|
if prev_pos is not None:
|
|
# Start background rendering for previous pages
|
|
self.buffer.start_background_rendering(end_position, 'backward')
|
|
|
|
return cached_page, prev_pos
|
|
|
|
# Cache hit for the page, but we don't have the reverse position
|
|
# Fall through to compute it below
|
|
|
|
# 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()
|