Coverage for pyWebLayout/layout/page_buffer.py: 83%
195 statements
« prev ^ index » next coverage.py v7.11.2, created at 2025-11-12 12:02 +0000
« prev ^ index » next coverage.py v7.11.2, created at 2025-11-12 12:02 +0000
1"""
2Multi-process page buffering system for high-performance ereader navigation.
4This module provides intelligent page caching with background rendering using
5multiprocessing to achieve sub-second page navigation performance.
6"""
8from __future__ import annotations
9from typing import Dict, Optional, List, Tuple, Any
10from collections import OrderedDict
11from concurrent.futures import ProcessPoolExecutor, Future
12import threading
13import pickle
15from .ereader_layout import RenderingPosition, BidirectionalLayouter, FontFamilyOverride
16from pyWebLayout.concrete.page import Page
17from pyWebLayout.abstract.block import Block
18from pyWebLayout.style.page_style import PageStyle
19from pyWebLayout.style.fonts import BundledFont
22def _render_page_worker(args: Tuple[List[Block],
23 PageStyle,
24 RenderingPosition,
25 float,
26 bool,
27 Optional[BundledFont]]) -> Tuple[RenderingPosition,
28 bytes,
29 RenderingPosition]:
30 """
31 Worker function for multiprocess page rendering.
33 Args:
34 args: Tuple of (blocks, page_style, position, font_scale, is_backward, font_family)
36 Returns:
37 Tuple of (original_position, pickled_page, next_position)
38 """
39 blocks, page_style, position, font_scale, is_backward, font_family = args
41 # Create font family override if specified
42 font_family_override = FontFamilyOverride(font_family) if font_family else None
44 layouter = BidirectionalLayouter(blocks, page_style, font_family_override=font_family_override)
46 if is_backward:
47 page, next_pos = layouter.render_page_backward(position, font_scale)
48 else:
49 page, next_pos = layouter.render_page_forward(position, font_scale)
51 # Serialize the page for inter-process communication
52 pickled_page = pickle.dumps(page)
54 return position, pickled_page, next_pos
57class PageBuffer:
58 """
59 Intelligent page caching system with LRU eviction and background rendering.
60 Maintains separate forward and backward buffers for optimal navigation performance.
61 """
63 def __init__(self, buffer_size: int = 5, max_workers: int = 4):
64 """
65 Initialize the page buffer.
67 Args:
68 buffer_size: Number of pages to cache in each direction
69 max_workers: Maximum number of worker processes for background rendering
70 """
71 self.buffer_size = buffer_size
72 self.max_workers = max_workers
74 # LRU caches for forward and backward pages
75 self.forward_buffer: OrderedDict[RenderingPosition, Page] = OrderedDict()
76 self.backward_buffer: OrderedDict[RenderingPosition, Page] = OrderedDict()
78 # Position tracking for next/previous positions
79 self.position_map: Dict[RenderingPosition,
80 RenderingPosition] = {} # current -> next
81 self.reverse_position_map: Dict[RenderingPosition,
82 RenderingPosition] = {} # current -> previous
84 # Background rendering
85 self.executor: Optional[ProcessPoolExecutor] = None
86 self.pending_renders: Dict[RenderingPosition, Future] = {}
87 self.render_lock = threading.Lock()
89 # Document state
90 self.blocks: Optional[List[Block]] = None
91 self.page_style: Optional[PageStyle] = None
92 self.current_font_scale: float = 1.0
93 self.current_font_family: Optional[BundledFont] = None
95 def initialize(
96 self,
97 blocks: List[Block],
98 page_style: PageStyle,
99 font_scale: float = 1.0,
100 font_family: Optional[BundledFont] = None):
101 """
102 Initialize the buffer with document blocks and page style.
104 Args:
105 blocks: Document blocks to render
106 page_style: Page styling configuration
107 font_scale: Current font scaling factor
108 font_family: Optional font family override
109 """
110 self.blocks = blocks
111 self.page_style = page_style
112 self.current_font_scale = font_scale
113 self.current_font_family = font_family
115 # Start the process pool
116 if self.executor is None: 116 ↛ exitline 116 didn't return from function 'initialize' because the condition on line 116 was always true
117 self.executor = ProcessPoolExecutor(max_workers=self.max_workers)
119 def get_page(self, position: RenderingPosition) -> Optional[Page]:
120 """
121 Get a cached page if available.
123 Args:
124 position: Position to get page for
126 Returns:
127 Cached page or None if not available
128 """
129 # Check forward buffer first
130 if position in self.forward_buffer:
131 # Move to end (most recently used)
132 page = self.forward_buffer.pop(position)
133 self.forward_buffer[position] = page
134 return page
136 # Check backward buffer
137 if position in self.backward_buffer:
138 # Move to end (most recently used)
139 page = self.backward_buffer.pop(position)
140 self.backward_buffer[position] = page
141 return page
143 return None
145 def cache_page(
146 self,
147 position: RenderingPosition,
148 page: Page,
149 next_position: Optional[RenderingPosition] = None,
150 is_backward: bool = False):
151 """
152 Cache a rendered page with LRU eviction.
154 Args:
155 position: Position of the page
156 page: Rendered page to cache
157 next_position: Position of the next page (for forward navigation)
158 is_backward: Whether this is a backward-rendered page
159 """
160 target_buffer = self.backward_buffer if is_backward else self.forward_buffer
162 # Add to cache
163 target_buffer[position] = page
165 # Track position relationships
166 if next_position: 166 ↛ 173line 166 didn't jump to line 173 because the condition on line 166 was always true
167 if is_backward:
168 self.reverse_position_map[next_position] = position
169 else:
170 self.position_map[position] = next_position
172 # Evict oldest if buffer is full
173 if len(target_buffer) > self.buffer_size:
174 oldest_pos, _ = target_buffer.popitem(last=False)
175 # Clean up position maps
176 self.position_map.pop(oldest_pos, None)
177 self.reverse_position_map.pop(oldest_pos, None)
179 def start_background_rendering(
180 self,
181 current_position: RenderingPosition,
182 direction: str = 'forward'):
183 """
184 Start background rendering of upcoming pages.
186 Args:
187 current_position: Current reading position
188 direction: 'forward', 'backward', or 'both'
189 """
190 if not self.blocks or not self.page_style or not self.executor: 190 ↛ 191line 190 didn't jump to line 191 because the condition on line 190 was never true
191 return
193 with self.render_lock:
194 if direction in ['forward', 'both']: 194 ↛ 197line 194 didn't jump to line 197 because the condition on line 194 was always true
195 self._queue_forward_renders(current_position)
197 if direction in ['backward', 'both']:
198 self._queue_backward_renders(current_position)
200 def _queue_forward_renders(self, start_position: RenderingPosition):
201 """Queue forward page renders starting from the given position"""
202 current_pos = start_position
204 for i in range(self.buffer_size):
205 # Skip if already cached or being rendered
206 if current_pos in self.forward_buffer or current_pos in self.pending_renders:
207 # Try to get next position from cache
208 current_pos = self.position_map.get(current_pos)
209 if not current_pos:
210 break
211 continue
213 # Queue render job
214 args = (
215 self.blocks,
216 self.page_style,
217 current_pos,
218 self.current_font_scale,
219 False,
220 self.current_font_family)
221 future = self.executor.submit(_render_page_worker, args)
222 self.pending_renders[current_pos] = future
224 # We don't know the next position yet, so we'll update it when the render
225 # completes
226 break
228 def _queue_backward_renders(self, start_position: RenderingPosition):
229 """Queue backward page renders ending at the given position"""
230 current_pos = start_position
232 for i in range(self.buffer_size): 232 ↛ exitline 232 didn't return from function '_queue_backward_renders' because the loop on line 232 didn't complete
233 # Skip if already cached or being rendered
234 if current_pos in self.backward_buffer or current_pos in self.pending_renders:
235 # Try to get previous position from cache
236 current_pos = self.reverse_position_map.get(current_pos)
237 if not current_pos:
238 break
239 continue
241 # Queue render job
242 args = (
243 self.blocks,
244 self.page_style,
245 current_pos,
246 self.current_font_scale,
247 True,
248 self.current_font_family)
249 future = self.executor.submit(_render_page_worker, args)
250 self.pending_renders[current_pos] = future
252 # We don't know the previous position yet, so we'll update it when the
253 # render completes
254 break
256 def check_completed_renders(self):
257 """Check for completed background renders and cache the results"""
258 if not self.pending_renders: 258 ↛ 259line 258 didn't jump to line 259 because the condition on line 258 was never true
259 return
261 completed = []
263 with self.render_lock:
264 for position, future in self.pending_renders.items():
265 if future.done():
266 try:
267 original_pos, pickled_page, next_pos = future.result()
269 # Deserialize the page
270 page = pickle.loads(pickled_page)
272 # Cache the page
273 self.cache_page(original_pos, page, next_pos, is_backward=False)
275 completed.append(position)
277 except Exception as e:
278 print(f"Background render failed for position {position}: {e}")
279 completed.append(position)
281 # Remove completed renders
282 for pos in completed:
283 self.pending_renders.pop(pos, None)
285 def invalidate_all(self):
286 """Clear all cached pages and cancel pending renders"""
287 with self.render_lock:
288 # Cancel pending renders
289 for future in self.pending_renders.values():
290 future.cancel()
291 self.pending_renders.clear()
293 # Clear caches
294 self.forward_buffer.clear()
295 self.backward_buffer.clear()
296 self.position_map.clear()
297 self.reverse_position_map.clear()
299 def set_font_scale(self, font_scale: float):
300 """
301 Update font scale and invalidate cache.
303 Args:
304 font_scale: New font scaling factor
305 """
306 if font_scale != self.current_font_scale: 306 ↛ exitline 306 didn't return from function 'set_font_scale' because the condition on line 306 was always true
307 self.current_font_scale = font_scale
308 self.invalidate_all()
310 def set_font_family(self, font_family: Optional[BundledFont]):
311 """
312 Update font family and invalidate cache.
314 Args:
315 font_family: New font family (None = use original fonts)
316 """
317 if font_family != self.current_font_family:
318 self.current_font_family = font_family
319 self.invalidate_all()
321 def get_cache_stats(self) -> Dict[str, Any]:
322 """Get cache statistics for debugging/monitoring"""
323 return {
324 'forward_buffer_size': len(self.forward_buffer),
325 'backward_buffer_size': len(self.backward_buffer),
326 'pending_renders': len(self.pending_renders),
327 'position_mappings': len(self.position_map),
328 'reverse_position_mappings': len(self.reverse_position_map),
329 'current_font_scale': self.current_font_scale,
330 'current_font_family': self.current_font_family.value if self.current_font_family else None
331 }
333 def shutdown(self):
334 """Shutdown the page buffer and clean up resources"""
335 if self.executor:
336 # Cancel pending renders
337 with self.render_lock:
338 for future in self.pending_renders.values():
339 future.cancel()
341 # Shutdown executor
342 self.executor.shutdown(wait=True)
343 self.executor = None
345 # Clear all caches
346 self.invalidate_all()
348 def __del__(self):
349 """Cleanup on destruction"""
350 self.shutdown()
353class BufferedPageRenderer:
354 """
355 High-level interface for buffered page rendering with automatic background caching.
356 """
358 def __init__(self,
359 blocks: List[Block],
360 page_style: PageStyle,
361 buffer_size: int = 5,
362 page_size: Tuple[int,
363 int] = (800,
364 600),
365 font_family: Optional[BundledFont] = None):
366 """
367 Initialize the buffered renderer.
369 Args:
370 blocks: Document blocks to render
371 page_style: Page styling configuration
372 buffer_size: Number of pages to cache in each direction
373 page_size: Page size (width, height) in pixels
374 font_family: Optional font family override
375 """
376 # Create font family override if specified
377 font_family_override = FontFamilyOverride(font_family) if font_family else None
379 self.layouter = BidirectionalLayouter(blocks, page_style, page_size, font_family_override=font_family_override)
380 self.buffer = PageBuffer(buffer_size)
381 self.buffer.initialize(blocks, page_style, font_family=font_family)
382 self.page_size = page_size
383 self.blocks = blocks
384 self.page_style = page_style
386 self.current_position = RenderingPosition()
387 self.font_scale = 1.0
388 self.font_family = font_family
390 def render_page(self, position: RenderingPosition,
391 font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
392 """
393 Render a page with intelligent caching.
395 Args:
396 position: Position to render from
397 font_scale: Font scaling factor
399 Returns:
400 Tuple of (rendered_page, next_position)
401 """
402 # Update font scale if changed
403 if font_scale != self.font_scale:
404 self.font_scale = font_scale
405 self.buffer.set_font_scale(font_scale)
407 # Check cache first
408 cached_page = self.buffer.get_page(position)
409 if cached_page:
410 # Get next position from position map
411 next_pos = self.buffer.position_map.get(position)
413 # Only use cache if we have the forward position mapping
414 # Otherwise, we need to compute it
415 if next_pos is not None:
416 # Start background rendering for upcoming pages
417 self.buffer.start_background_rendering(position, 'forward')
419 return cached_page, next_pos
421 # Cache hit for the page, but we don't have the forward position
422 # Fall through to compute it below
424 # Render the page directly
425 page, next_pos = self.layouter.render_page_forward(position, font_scale)
427 # Cache the result
428 self.buffer.cache_page(position, page, next_pos)
430 # Start background rendering
431 self.buffer.start_background_rendering(position, 'both')
433 # Check for completed background renders
434 self.buffer.check_completed_renders()
436 return page, next_pos
438 def render_page_backward(self,
439 end_position: RenderingPosition,
440 font_scale: float = 1.0) -> Tuple[Page,
441 RenderingPosition]:
442 """
443 Render a page ending at the given position with intelligent caching.
445 Args:
446 end_position: Position where page should end
447 font_scale: Font scaling factor
449 Returns:
450 Tuple of (rendered_page, start_position)
451 """
452 # Update font scale if changed
453 if font_scale != self.font_scale: 453 ↛ 454line 453 didn't jump to line 454 because the condition on line 453 was never true
454 self.font_scale = font_scale
455 self.buffer.set_font_scale(font_scale)
457 # Check cache first
458 cached_page = self.buffer.get_page(end_position)
459 if cached_page: 459 ↛ 461line 459 didn't jump to line 461 because the condition on line 459 was never true
460 # Get previous position from reverse position map
461 prev_pos = self.buffer.reverse_position_map.get(end_position)
463 # Only use cache if we have the reverse position mapping
464 # Otherwise, we need to compute it
465 if prev_pos is not None:
466 # Start background rendering for previous pages
467 self.buffer.start_background_rendering(end_position, 'backward')
469 return cached_page, prev_pos
471 # Cache hit for the page, but we don't have the reverse position
472 # Fall through to compute it below
474 # Render the page directly
475 page, start_pos = self.layouter.render_page_backward(end_position, font_scale)
477 # Cache the result
478 self.buffer.cache_page(start_pos, page, end_position, is_backward=True)
480 # Start background rendering
481 self.buffer.start_background_rendering(end_position, 'both')
483 # Check for completed background renders
484 self.buffer.check_completed_renders()
486 return page, start_pos
488 def set_font_family(self, font_family: Optional[BundledFont]):
489 """
490 Change the font family and invalidate cache.
492 Args:
493 font_family: New font family (None = use original fonts)
494 """
495 if font_family != self.font_family:
496 self.font_family = font_family
498 # Update buffer
499 self.buffer.set_font_family(font_family)
501 # Recreate layouter with new font family override
502 font_family_override = FontFamilyOverride(font_family) if font_family else None
503 self.layouter = BidirectionalLayouter(
504 self.blocks,
505 self.page_style,
506 self.page_size,
507 font_family_override=font_family_override
508 )
510 def get_font_family(self) -> Optional[BundledFont]:
511 """Get the current font family override"""
512 return self.font_family
514 def get_cache_stats(self) -> Dict[str, Any]:
515 """Get cache statistics"""
516 return self.buffer.get_cache_stats()
518 def shutdown(self):
519 """Shutdown the renderer and clean up resources"""
520 self.buffer.shutdown()