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

1""" 

2Multi-process page buffering system for high-performance ereader navigation. 

3 

4This module provides intelligent page caching with background rendering using 

5multiprocessing to achieve sub-second page navigation performance. 

6""" 

7 

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 

14 

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 

20 

21 

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. 

32 

33 Args: 

34 args: Tuple of (blocks, page_style, position, font_scale, is_backward, font_family) 

35 

36 Returns: 

37 Tuple of (original_position, pickled_page, next_position) 

38 """ 

39 blocks, page_style, position, font_scale, is_backward, font_family = args 

40 

41 # Create font family override if specified 

42 font_family_override = FontFamilyOverride(font_family) if font_family else None 

43 

44 layouter = BidirectionalLayouter(blocks, page_style, font_family_override=font_family_override) 

45 

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) 

50 

51 # Serialize the page for inter-process communication 

52 pickled_page = pickle.dumps(page) 

53 

54 return position, pickled_page, next_pos 

55 

56 

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 """ 

62 

63 def __init__(self, buffer_size: int = 5, max_workers: int = 4): 

64 """ 

65 Initialize the page buffer. 

66 

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 

73 

74 # LRU caches for forward and backward pages 

75 self.forward_buffer: OrderedDict[RenderingPosition, Page] = OrderedDict() 

76 self.backward_buffer: OrderedDict[RenderingPosition, Page] = OrderedDict() 

77 

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 

83 

84 # Background rendering 

85 self.executor: Optional[ProcessPoolExecutor] = None 

86 self.pending_renders: Dict[RenderingPosition, Future] = {} 

87 self.render_lock = threading.Lock() 

88 

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 

94 

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. 

103 

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 

114 

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) 

118 

119 def get_page(self, position: RenderingPosition) -> Optional[Page]: 

120 """ 

121 Get a cached page if available. 

122 

123 Args: 

124 position: Position to get page for 

125 

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 

135 

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 

142 

143 return None 

144 

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. 

153 

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 

161 

162 # Add to cache 

163 target_buffer[position] = page 

164 

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 

171 

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) 

178 

179 def start_background_rendering( 

180 self, 

181 current_position: RenderingPosition, 

182 direction: str = 'forward'): 

183 """ 

184 Start background rendering of upcoming pages. 

185 

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 

192 

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) 

196 

197 if direction in ['backward', 'both']: 

198 self._queue_backward_renders(current_position) 

199 

200 def _queue_forward_renders(self, start_position: RenderingPosition): 

201 """Queue forward page renders starting from the given position""" 

202 current_pos = start_position 

203 

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 

212 

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 

223 

224 # We don't know the next position yet, so we'll update it when the render 

225 # completes 

226 break 

227 

228 def _queue_backward_renders(self, start_position: RenderingPosition): 

229 """Queue backward page renders ending at the given position""" 

230 current_pos = start_position 

231 

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 

240 

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 

251 

252 # We don't know the previous position yet, so we'll update it when the 

253 # render completes 

254 break 

255 

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 

260 

261 completed = [] 

262 

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() 

268 

269 # Deserialize the page 

270 page = pickle.loads(pickled_page) 

271 

272 # Cache the page 

273 self.cache_page(original_pos, page, next_pos, is_backward=False) 

274 

275 completed.append(position) 

276 

277 except Exception as e: 

278 print(f"Background render failed for position {position}: {e}") 

279 completed.append(position) 

280 

281 # Remove completed renders 

282 for pos in completed: 

283 self.pending_renders.pop(pos, None) 

284 

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() 

292 

293 # Clear caches 

294 self.forward_buffer.clear() 

295 self.backward_buffer.clear() 

296 self.position_map.clear() 

297 self.reverse_position_map.clear() 

298 

299 def set_font_scale(self, font_scale: float): 

300 """ 

301 Update font scale and invalidate cache. 

302 

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() 

309 

310 def set_font_family(self, font_family: Optional[BundledFont]): 

311 """ 

312 Update font family and invalidate cache. 

313 

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() 

320 

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 } 

332 

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() 

340 

341 # Shutdown executor 

342 self.executor.shutdown(wait=True) 

343 self.executor = None 

344 

345 # Clear all caches 

346 self.invalidate_all() 

347 

348 def __del__(self): 

349 """Cleanup on destruction""" 

350 self.shutdown() 

351 

352 

353class BufferedPageRenderer: 

354 """ 

355 High-level interface for buffered page rendering with automatic background caching. 

356 """ 

357 

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. 

368 

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 

378 

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 

385 

386 self.current_position = RenderingPosition() 

387 self.font_scale = 1.0 

388 self.font_family = font_family 

389 

390 def render_page(self, position: RenderingPosition, 

391 font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]: 

392 """ 

393 Render a page with intelligent caching. 

394 

395 Args: 

396 position: Position to render from 

397 font_scale: Font scaling factor 

398 

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) 

406 

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) 

412 

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') 

418 

419 return cached_page, next_pos 

420 

421 # Cache hit for the page, but we don't have the forward position 

422 # Fall through to compute it below 

423 

424 # Render the page directly 

425 page, next_pos = self.layouter.render_page_forward(position, font_scale) 

426 

427 # Cache the result 

428 self.buffer.cache_page(position, page, next_pos) 

429 

430 # Start background rendering 

431 self.buffer.start_background_rendering(position, 'both') 

432 

433 # Check for completed background renders 

434 self.buffer.check_completed_renders() 

435 

436 return page, next_pos 

437 

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. 

444 

445 Args: 

446 end_position: Position where page should end 

447 font_scale: Font scaling factor 

448 

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) 

456 

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) 

462 

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') 

468 

469 return cached_page, prev_pos 

470 

471 # Cache hit for the page, but we don't have the reverse position 

472 # Fall through to compute it below 

473 

474 # Render the page directly 

475 page, start_pos = self.layouter.render_page_backward(end_position, font_scale) 

476 

477 # Cache the result 

478 self.buffer.cache_page(start_pos, page, end_position, is_backward=True) 

479 

480 # Start background rendering 

481 self.buffer.start_background_rendering(end_position, 'both') 

482 

483 # Check for completed background renders 

484 self.buffer.check_completed_renders() 

485 

486 return page, start_pos 

487 

488 def set_font_family(self, font_family: Optional[BundledFont]): 

489 """ 

490 Change the font family and invalidate cache. 

491 

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 

497 

498 # Update buffer 

499 self.buffer.set_font_family(font_family) 

500 

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 ) 

509 

510 def get_font_family(self) -> Optional[BundledFont]: 

511 """Get the current font family override""" 

512 return self.font_family 

513 

514 def get_cache_stats(self) -> Dict[str, Any]: 

515 """Get cache statistics""" 

516 return self.buffer.get_cache_stats() 

517 

518 def shutdown(self): 

519 """Shutdown the renderer and clean up resources""" 

520 self.buffer.shutdown()