Coverage for pyWebLayout/layout/ereader_manager.py: 84%

292 statements  

« prev     ^ index     » next       coverage.py v7.11.2, created at 2025-11-12 12:02 +0000

1""" 

2High-performance ereader layout manager with sub-second page rendering. 

3 

4This module provides the main interface for ereader applications, combining 

5position tracking, font scaling, chapter navigation, and intelligent page buffering 

6into a unified, easy-to-use API. 

7""" 

8 

9from __future__ import annotations 

10from typing import List, Dict, Optional, Tuple, Any, Callable 

11import json 

12from pathlib import Path 

13 

14from .ereader_layout import RenderingPosition, ChapterNavigator, ChapterInfo 

15from .page_buffer import BufferedPageRenderer 

16from pyWebLayout.abstract.block import Block, HeadingLevel, Image, BlockType 

17from pyWebLayout.concrete.page import Page 

18from pyWebLayout.concrete.image import RenderableImage 

19from pyWebLayout.style.page_style import PageStyle 

20from pyWebLayout.style.fonts import BundledFont 

21from pyWebLayout.layout.document_layouter import image_layouter 

22 

23 

24class BookmarkManager: 

25 """ 

26 Manages bookmarks and reading position persistence for ereader applications. 

27 """ 

28 

29 def __init__(self, document_id: str, bookmarks_dir: str = "bookmarks"): 

30 """ 

31 Initialize bookmark manager. 

32 

33 Args: 

34 document_id: Unique identifier for the document 

35 bookmarks_dir: Directory to store bookmark files 

36 """ 

37 self.document_id = document_id 

38 self.bookmarks_dir = Path(bookmarks_dir) 

39 self.bookmarks_dir.mkdir(exist_ok=True) 

40 

41 self.bookmarks_file = self.bookmarks_dir / f"{document_id}_bookmarks.json" 

42 self.position_file = self.bookmarks_dir / f"{document_id}_position.json" 

43 

44 self._bookmarks: Dict[str, RenderingPosition] = {} 

45 self._load_bookmarks() 

46 

47 def _load_bookmarks(self): 

48 """Load bookmarks from file""" 

49 if self.bookmarks_file.exists(): 

50 try: 

51 with open(self.bookmarks_file, 'r') as f: 

52 data = json.load(f) 

53 self._bookmarks = { 

54 name: RenderingPosition.from_dict(pos_data) 

55 for name, pos_data in data.items() 

56 } 

57 except Exception as e: 

58 print(f"Failed to load bookmarks: {e}") 

59 self._bookmarks = {} 

60 

61 def _save_bookmarks(self): 

62 """Save bookmarks to file""" 

63 try: 

64 data = { 

65 name: position.to_dict() 

66 for name, position in self._bookmarks.items() 

67 } 

68 with open(self.bookmarks_file, 'w') as f: 

69 json.dump(data, f, indent=2) 

70 except Exception as e: 

71 print(f"Failed to save bookmarks: {e}") 

72 

73 def add_bookmark(self, name: str, position: RenderingPosition): 

74 """ 

75 Add a bookmark at the given position. 

76 

77 Args: 

78 name: Bookmark name 

79 position: Position to bookmark 

80 """ 

81 self._bookmarks[name] = position 

82 self._save_bookmarks() 

83 

84 def remove_bookmark(self, name: str) -> bool: 

85 """ 

86 Remove a bookmark. 

87 

88 Args: 

89 name: Bookmark name to remove 

90 

91 Returns: 

92 True if bookmark was removed, False if not found 

93 """ 

94 if name in self._bookmarks: 

95 del self._bookmarks[name] 

96 self._save_bookmarks() 

97 return True 

98 return False 

99 

100 def get_bookmark(self, name: str) -> Optional[RenderingPosition]: 

101 """ 

102 Get a bookmark position. 

103 

104 Args: 

105 name: Bookmark name 

106 

107 Returns: 

108 Bookmark position or None if not found 

109 """ 

110 return self._bookmarks.get(name) 

111 

112 def list_bookmarks(self) -> List[Tuple[str, RenderingPosition]]: 

113 """ 

114 Get all bookmarks. 

115 

116 Returns: 

117 List of (name, position) tuples 

118 """ 

119 return list(self._bookmarks.items()) 

120 

121 def save_reading_position(self, position: RenderingPosition): 

122 """ 

123 Save the current reading position. 

124 

125 Args: 

126 position: Current reading position 

127 """ 

128 try: 

129 with open(self.position_file, 'w') as f: 

130 json.dump(position.to_dict(), f, indent=2) 

131 except Exception as e: 

132 print(f"Failed to save reading position: {e}") 

133 

134 def load_reading_position(self) -> Optional[RenderingPosition]: 

135 """ 

136 Load the last reading position. 

137 

138 Returns: 

139 Last reading position or None if not found 

140 """ 

141 if self.position_file.exists(): 

142 try: 

143 with open(self.position_file, 'r') as f: 

144 data = json.load(f) 

145 return RenderingPosition.from_dict(data) 

146 except Exception as e: 

147 print(f"Failed to load reading position: {e}") 

148 return None 

149 

150 

151class EreaderLayoutManager: 

152 """ 

153 High-level ereader layout manager providing a complete interface for ereader applications. 

154 

155 Features: 

156 - Sub-second page rendering with intelligent buffering 

157 - Font scaling support 

158 - Dynamic font family switching (Sans, Serif, Monospace) 

159 - Chapter navigation 

160 - Bookmark management 

161 - Position persistence 

162 - Progress tracking 

163 """ 

164 

165 def __init__(self, 

166 blocks: List[Block], 

167 page_size: Tuple[int, int], 

168 document_id: str = "default", 

169 buffer_size: int = 5, 

170 page_style: Optional[PageStyle] = None, 

171 bookmarks_dir: str = "bookmarks"): 

172 """ 

173 Initialize the ereader layout manager. 

174 

175 Args: 

176 blocks: Document blocks to render 

177 page_size: Page size (width, height) in pixels 

178 document_id: Unique identifier for the document (for bookmarks/position) 

179 buffer_size: Number of pages to cache in each direction 

180 page_style: Custom page styling (uses default if None) 

181 bookmarks_dir: Directory to store bookmark files 

182 """ 

183 self.blocks = blocks 

184 self.page_size = page_size 

185 self.document_id = document_id 

186 

187 # Initialize page style 

188 if page_style is None: 

189 page_style = PageStyle() 

190 self.page_style = page_style 

191 

192 # Initialize core components 

193 self.renderer = BufferedPageRenderer(blocks, page_style, buffer_size, page_size) 

194 self.chapter_navigator = ChapterNavigator(blocks) 

195 self.bookmark_manager = BookmarkManager(document_id, bookmarks_dir) 

196 

197 # Current state 

198 self.current_position = RenderingPosition() 

199 self.font_scale = 1.0 

200 

201 # Cover page handling 

202 self._has_cover = self._detect_cover() 

203 self._on_cover_page = self._has_cover # Start on cover if one exists 

204 

205 # Page position history for fast backward navigation 

206 # List of (position, font_scale) tuples representing the start of each page visited 

207 self._page_history: List[Tuple[RenderingPosition, float]] = [] 

208 self._max_history_size = 50 # Keep last 50 page positions 

209 

210 # Load last reading position if available 

211 saved_position = self.bookmark_manager.load_reading_position() 

212 if saved_position: 

213 self.current_position = saved_position 

214 self._on_cover_page = False # If we have a saved position, we're past the cover 

215 

216 # Callbacks for UI updates 

217 self.position_changed_callback: Optional[Callable[[ 

218 RenderingPosition], None]] = None 

219 self.chapter_changed_callback: Optional[Callable[[ 

220 Optional[ChapterInfo]], None]] = None 

221 

222 def set_position_changed_callback( 

223 self, callback: Callable[[RenderingPosition], None]): 

224 """Set callback for position changes""" 

225 self.position_changed_callback = callback 

226 

227 def set_chapter_changed_callback( 

228 self, callback: Callable[[Optional[ChapterInfo]], None]): 

229 """Set callback for chapter changes""" 

230 self.chapter_changed_callback = callback 

231 

232 def _detect_cover(self) -> bool: 

233 """ 

234 Detect if the document has a cover page. 

235 

236 A cover is detected if: 

237 1. The first block is an Image block, OR 

238 2. The document has cover metadata (future enhancement) 

239 

240 Returns: 

241 True if a cover page should be rendered 

242 """ 

243 if not self.blocks: 

244 return False 

245 

246 # Check if first block is an image - treat it as a cover 

247 first_block = self.blocks[0] 

248 if isinstance(first_block, Image): 

249 return True 

250 

251 return False 

252 

253 def _render_cover_page(self) -> Page: 

254 """ 

255 Render a dedicated cover page. 

256 

257 The cover page displays the first image block (if it exists) 

258 using the standard image layouter with maximum dimensions to fill the page. 

259 

260 Returns: 

261 Rendered cover page 

262 """ 

263 # Create a new page for the cover 

264 page = Page(self.page_size, self.page_style) 

265 

266 if not self.blocks or not isinstance(self.blocks[0], Image): 266 ↛ 268line 266 didn't jump to line 268 because the condition on line 266 was never true

267 # No cover image, return blank page 

268 return page 

269 

270 cover_image_block = self.blocks[0] 

271 

272 # Use the image layouter to render the cover image 

273 # Use full page dimensions (minus borders/padding) for cover 

274 try: 

275 max_width = self.page_size[0] - 2 * self.page_style.border_width 

276 max_height = self.page_size[1] - 2 * self.page_style.border_width 

277 

278 # Layout the image on the page 

279 success = image_layouter( 

280 image=cover_image_block, 

281 page=page, 

282 max_width=max_width, 

283 max_height=max_height 

284 ) 

285 

286 if not success: 286 ↛ 293line 286 didn't jump to line 293 because the condition on line 286 was always true

287 print("Warning: Failed to layout cover image") 

288 

289 except Exception as e: 

290 # If image loading fails, just return the blank page 

291 print(f"Warning: Failed to load cover image: {e}") 

292 

293 return page 

294 

295 def _notify_position_changed(self): 

296 """Notify UI of position change""" 

297 if self.position_changed_callback: 

298 self.position_changed_callback(self.current_position) 

299 

300 # Check if chapter changed 

301 current_chapter = self.chapter_navigator.get_current_chapter( 

302 self.current_position) 

303 if self.chapter_changed_callback: 

304 self.chapter_changed_callback(current_chapter) 

305 

306 # Auto-save reading position 

307 self.bookmark_manager.save_reading_position(self.current_position) 

308 

309 def get_current_page(self) -> Page: 

310 """ 

311 Get the page at the current reading position. 

312 

313 If on the cover page, returns the rendered cover. 

314 Otherwise, returns the regular content page. 

315 

316 Returns: 

317 Rendered page 

318 """ 

319 # Check if we're on the cover page 

320 if self._on_cover_page and self._has_cover: 

321 return self._render_cover_page() 

322 

323 page, _ = self.renderer.render_page(self.current_position, self.font_scale) 

324 return page 

325 

326 def next_page(self) -> Optional[Page]: 

327 """ 

328 Advance to the next page. 

329 

330 If currently on the cover page, advances to the first content page. 

331 Otherwise, advances to the next content page. 

332 

333 Returns: 

334 Next page or None if at end of document 

335 """ 

336 # Special case: transitioning from cover to first content page 

337 if self._on_cover_page and self._has_cover: 

338 self._on_cover_page = False 

339 # If first block is an image (the cover), skip it and start from block 1 

340 if self.blocks and isinstance(self.blocks[0], Image): 340 ↛ 343line 340 didn't jump to line 343 because the condition on line 340 was always true

341 self.current_position = RenderingPosition(chapter_index=0, block_index=1) 

342 else: 

343 self.current_position = RenderingPosition() 

344 self._notify_position_changed() 

345 return self.get_current_page() 

346 

347 # Save current position to history before moving forward 

348 self._add_to_history(self.current_position, self.font_scale) 

349 

350 page, next_position = self.renderer.render_page( 

351 self.current_position, self.font_scale) 

352 

353 # Check if we made progress 

354 if next_position != self.current_position: 

355 self.current_position = next_position 

356 self._notify_position_changed() 

357 return self.get_current_page() 

358 

359 return None # At end of document 

360 

361 def previous_page(self) -> Optional[Page]: 

362 """ 

363 Go to the previous page. 

364 

365 Uses cached page history for instant navigation when available, 

366 falls back to iterative refinement algorithm when needed. 

367 Can navigate back to the cover page if it exists. 

368 

369 Returns: 

370 Previous page or None if at beginning of document (or on cover) 

371 """ 

372 # Special case: if at the beginning of content and there's a cover, go back to it 

373 if self._has_cover and self._is_at_beginning() and not self._on_cover_page: 

374 self._on_cover_page = True 

375 self._notify_position_changed() 

376 return self.get_current_page() 

377 

378 # Can't go before the cover 

379 if self._on_cover_page: 379 ↛ 380line 379 didn't jump to line 380 because the condition on line 379 was never true

380 return None 

381 

382 if self._is_at_beginning(): 

383 return None 

384 

385 # Fast path: Check if we have this position in history 

386 previous_position = self._get_from_history(self.current_position, self.font_scale) 

387 

388 if previous_position is not None: 

389 # Cache hit! Use the cached position for instant navigation 

390 self.current_position = previous_position 

391 self._notify_position_changed() 

392 return self.get_current_page() 

393 

394 # Slow path: Use backward rendering to find the previous page 

395 # This uses the iterative refinement algorithm we just fixed 

396 page, start_position = self.renderer.render_page_backward( 

397 self.current_position, self.font_scale) 

398 

399 if start_position != self.current_position: 399 ↛ 407line 399 didn't jump to line 407 because the condition on line 399 was always true

400 # Save this calculated position to history for future use 

401 self._add_to_history(start_position, self.font_scale) 

402 

403 self.current_position = start_position 

404 self._notify_position_changed() 

405 return page 

406 

407 return None # At beginning of document 

408 

409 def _is_at_beginning(self) -> bool: 

410 """ 

411 Check if we're at the beginning of the document content. 

412 

413 If a cover exists (first block is an Image), the beginning of content 

414 is at block_index=1. Otherwise, it's at block_index=0. 

415 """ 

416 # Determine the first content block index 

417 first_content_block = 1 if (self._has_cover and self.blocks and isinstance(self.blocks[0], Image)) else 0 

418 

419 return (self.current_position.chapter_index == 0 and 

420 self.current_position.block_index == first_content_block and 

421 self.current_position.word_index == 0) 

422 

423 def jump_to_position(self, position: RenderingPosition) -> Page: 

424 """ 

425 Jump to a specific position in the document. 

426 

427 Args: 

428 position: Position to jump to 

429 

430 Returns: 

431 Page at the new position 

432 """ 

433 self.current_position = position 

434 self._on_cover_page = False # Jumping to a position means we're past the cover 

435 self._notify_position_changed() 

436 return self.get_current_page() 

437 

438 def jump_to_chapter(self, chapter_title: str) -> Optional[Page]: 

439 """ 

440 Jump to a specific chapter by title. 

441 

442 Args: 

443 chapter_title: Title of the chapter to jump to 

444 

445 Returns: 

446 Page at chapter start or None if chapter not found 

447 """ 

448 position = self.chapter_navigator.get_chapter_position(chapter_title) 

449 if position: 

450 return self.jump_to_position(position) 

451 return None 

452 

453 def jump_to_chapter_index(self, chapter_index: int) -> Optional[Page]: 

454 """ 

455 Jump to a chapter by index. 

456 

457 Args: 

458 chapter_index: Index of the chapter (0-based) 

459 

460 Returns: 

461 Page at chapter start or None if index invalid 

462 """ 

463 chapters = self.chapter_navigator.chapters 

464 if 0 <= chapter_index < len(chapters): 

465 return self.jump_to_position(chapters[chapter_index].position) 

466 return None 

467 

468 def _add_to_history(self, position: RenderingPosition, font_scale: float): 

469 """ 

470 Add a page position to the navigation history. 

471 

472 Args: 

473 position: The page start position to remember 

474 font_scale: The font scale at this position 

475 """ 

476 # Only add if it's different from the last entry 

477 if not self._page_history or \ 

478 self._page_history[-1][0] != position or \ 

479 self._page_history[-1][1] != font_scale: 

480 

481 self._page_history.append((position.copy(), font_scale)) 

482 

483 # Trim history if it exceeds max size 

484 if len(self._page_history) > self._max_history_size: 484 ↛ 485line 484 didn't jump to line 485 because the condition on line 484 was never true

485 self._page_history.pop(0) 

486 

487 def _get_from_history( 

488 self, 

489 current_position: RenderingPosition, 

490 current_font_scale: float) -> Optional[RenderingPosition]: 

491 """ 

492 Get the previous page position from history. 

493 

494 Searches backward through history to find the last position that 

495 comes before the current position at the same font scale. 

496 

497 Args: 

498 current_position: Current page position 

499 current_font_scale: Current font scale 

500 

501 Returns: 

502 Previous page position or None if not found in history 

503 """ 

504 # Search backward through history 

505 for i in range(len(self._page_history) - 1, -1, -1): 

506 hist_position, hist_font_scale = self._page_history[i] 

507 

508 # Must match font scale 

509 if hist_font_scale != current_font_scale: 509 ↛ 510line 509 didn't jump to line 510 because the condition on line 509 was never true

510 continue 

511 

512 # Must be before current position 

513 if (hist_position.chapter_index < current_position.chapter_index or 

514 (hist_position.chapter_index == current_position.chapter_index and 

515 hist_position.block_index < current_position.block_index) or 

516 (hist_position.chapter_index == current_position.chapter_index and 

517 hist_position.block_index == current_position.block_index and 

518 hist_position.word_index < current_position.word_index)): 

519 

520 # Found a previous position - remove it and everything after from history 

521 # since we're navigating backward 

522 self._page_history = self._page_history[:i] 

523 return hist_position.copy() 

524 

525 return None 

526 

527 def _clear_history(self): 

528 """Clear the page navigation history.""" 

529 self._page_history.clear() 

530 

531 def set_font_scale(self, scale: float) -> Page: 

532 """ 

533 Change the font scale and re-render current page. 

534 

535 Clears page history since font changes invalidate all cached positions. 

536 

537 Args: 

538 scale: Font scaling factor (1.0 = normal, 2.0 = double size, etc.) 

539 

540 Returns: 

541 Re-rendered page with new font scale 

542 """ 

543 if scale != self.font_scale: 

544 self.font_scale = scale 

545 # Clear history since font scale changes invalidate all cached positions 

546 self._clear_history() 

547 # The renderer will handle cache invalidation 

548 

549 return self.get_current_page() 

550 

551 def get_font_scale(self) -> float: 

552 """Get the current font scale""" 

553 return self.font_scale 

554 

555 def set_font_family(self, family: Optional[BundledFont]) -> Page: 

556 """ 

557 Change the font family and re-render current page. 

558 

559 Switches all text in the document to use the specified bundled font family 

560 while preserving font weights, styles, sizes, and other attributes. 

561 Clears page history and cache since font changes invalidate all cached positions. 

562 

563 Args: 

564 family: Bundled font family to use (SANS, SERIF, MONOSPACE, or None for original fonts) 

565 

566 Returns: 

567 Re-rendered page with new font family 

568 

569 Example: 

570 >>> from pyWebLayout.style.fonts import BundledFont 

571 >>> manager.set_font_family(BundledFont.SERIF) # Switch to serif 

572 >>> manager.set_font_family(BundledFont.SANS) # Switch to sans 

573 >>> manager.set_font_family(None) # Restore original fonts 

574 """ 

575 # Update the renderer's font family 

576 self.renderer.set_font_family(family) 

577 

578 # Clear history since font changes invalidate all cached positions 

579 self._clear_history() 

580 

581 return self.get_current_page() 

582 

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

584 """ 

585 Get the current font family override. 

586 

587 Returns: 

588 Current font family (SANS, SERIF, MONOSPACE) or None if using original fonts 

589 """ 

590 return self.renderer.get_font_family() 

591 

592 def increase_line_spacing(self, amount: int = 2) -> Page: 

593 """ 

594 Increase line spacing and re-render current page. 

595 

596 Clears page history since spacing changes invalidate all cached positions. 

597 

598 Args: 

599 amount: Pixels to add to line spacing (default: 2) 

600 

601 Returns: 

602 Re-rendered page with increased line spacing 

603 """ 

604 self.page_style.line_spacing += amount 

605 self.renderer.page_style = self.page_style # Update renderer's reference 

606 self.renderer.buffer.invalidate_all() # Clear cache to force re-render 

607 self._clear_history() # Clear position history 

608 return self.get_current_page() 

609 

610 def decrease_line_spacing(self, amount: int = 2) -> Page: 

611 """ 

612 Decrease line spacing and re-render current page. 

613 

614 Clears page history since spacing changes invalidate all cached positions. 

615 

616 Args: 

617 amount: Pixels to remove from line spacing (default: 2) 

618 

619 Returns: 

620 Re-rendered page with decreased line spacing 

621 """ 

622 self.page_style.line_spacing = max(0, self.page_style.line_spacing - amount) 

623 self.renderer.page_style = self.page_style # Update renderer's reference 

624 self.renderer.buffer.invalidate_all() # Clear cache to force re-render 

625 self._clear_history() # Clear position history 

626 return self.get_current_page() 

627 

628 def increase_inter_block_spacing(self, amount: int = 5) -> Page: 

629 """ 

630 Increase spacing between blocks and re-render current page. 

631 

632 Clears page history since spacing changes invalidate all cached positions. 

633 

634 Args: 

635 amount: Pixels to add to inter-block spacing (default: 5) 

636 

637 Returns: 

638 Re-rendered page with increased block spacing 

639 """ 

640 self.page_style.inter_block_spacing += amount 

641 self.renderer.page_style = self.page_style # Update renderer's reference 

642 self.renderer.buffer.invalidate_all() # Clear cache to force re-render 

643 self._clear_history() # Clear position history 

644 return self.get_current_page() 

645 

646 def decrease_inter_block_spacing(self, amount: int = 5) -> Page: 

647 """ 

648 Decrease spacing between blocks and re-render current page. 

649 

650 Clears page history since spacing changes invalidate all cached positions. 

651 

652 Args: 

653 amount: Pixels to remove from inter-block spacing (default: 5) 

654 

655 Returns: 

656 Re-rendered page with decreased block spacing 

657 """ 

658 self.page_style.inter_block_spacing = max( 

659 0, self.page_style.inter_block_spacing - amount) 

660 self.renderer.page_style = self.page_style # Update renderer's reference 

661 self.renderer.buffer.invalidate_all() # Clear cache to force re-render 

662 self._clear_history() # Clear position history 

663 return self.get_current_page() 

664 

665 def increase_word_spacing(self, amount: int = 2) -> Page: 

666 """ 

667 Increase spacing between words and re-render current page. 

668 

669 Clears page history since spacing changes invalidate all cached positions. 

670 

671 Args: 

672 amount: Pixels to add to word spacing (default: 2) 

673 

674 Returns: 

675 Re-rendered page with increased word spacing 

676 """ 

677 self.page_style.word_spacing += amount 

678 self.renderer.page_style = self.page_style # Update renderer's reference 

679 self.renderer.buffer.invalidate_all() # Clear cache to force re-render 

680 self._clear_history() # Clear position history 

681 return self.get_current_page() 

682 

683 def decrease_word_spacing(self, amount: int = 2) -> Page: 

684 """ 

685 Decrease spacing between words and re-render current page. 

686 

687 Clears page history since spacing changes invalidate all cached positions. 

688 

689 Args: 

690 amount: Pixels to remove from word spacing (default: 2) 

691 

692 Returns: 

693 Re-rendered page with decreased word spacing 

694 """ 

695 self.page_style.word_spacing = max(0, self.page_style.word_spacing - amount) 

696 self.renderer.page_style = self.page_style # Update renderer's reference 

697 self.renderer.buffer.invalidate_all() # Clear cache to force re-render 

698 self._clear_history() # Clear position history 

699 return self.get_current_page() 

700 

701 def get_table_of_contents( 

702 self) -> List[Tuple[str, HeadingLevel, RenderingPosition]]: 

703 """ 

704 Get the table of contents. 

705 

706 Returns: 

707 List of (title, level, position) tuples 

708 """ 

709 return self.chapter_navigator.get_table_of_contents() 

710 

711 def get_current_chapter(self) -> Optional[ChapterInfo]: 

712 """ 

713 Get information about the current chapter. 

714 

715 Returns: 

716 Current chapter info or None if no chapters 

717 """ 

718 return self.chapter_navigator.get_current_chapter(self.current_position) 

719 

720 def add_bookmark(self, name: str) -> bool: 

721 """ 

722 Add a bookmark at the current position. 

723 

724 Args: 

725 name: Bookmark name 

726 

727 Returns: 

728 True if bookmark was added successfully 

729 """ 

730 try: 

731 self.bookmark_manager.add_bookmark(name, self.current_position) 

732 return True 

733 except Exception: 

734 return False 

735 

736 def remove_bookmark(self, name: str) -> bool: 

737 """ 

738 Remove a bookmark. 

739 

740 Args: 

741 name: Bookmark name 

742 

743 Returns: 

744 True if bookmark was removed 

745 """ 

746 return self.bookmark_manager.remove_bookmark(name) 

747 

748 def jump_to_bookmark(self, name: str) -> Optional[Page]: 

749 """ 

750 Jump to a bookmark. 

751 

752 Args: 

753 name: Bookmark name 

754 

755 Returns: 

756 Page at bookmark position or None if bookmark not found 

757 """ 

758 position = self.bookmark_manager.get_bookmark(name) 

759 if position: 

760 return self.jump_to_position(position) 

761 return None 

762 

763 def list_bookmarks(self) -> List[Tuple[str, RenderingPosition]]: 

764 """ 

765 Get all bookmarks. 

766 

767 Returns: 

768 List of (name, position) tuples 

769 """ 

770 return self.bookmark_manager.list_bookmarks() 

771 

772 def get_reading_progress(self) -> float: 

773 """ 

774 Get reading progress as a percentage. 

775 

776 Returns: 

777 Progress from 0.0 to 1.0 

778 """ 

779 if not self.blocks: 

780 return 0.0 

781 

782 # Simple progress calculation based on block index 

783 # A more sophisticated version would consider word positions 

784 total_blocks = len(self.blocks) 

785 current_block = min(self.current_position.block_index, total_blocks - 1) 

786 

787 return current_block / max(1, total_blocks - 1) 

788 

789 def has_cover(self) -> bool: 

790 """ 

791 Check if the document has a cover page. 

792 

793 Returns: 

794 True if a cover page is available 

795 """ 

796 return self._has_cover 

797 

798 def is_on_cover(self) -> bool: 

799 """ 

800 Check if currently viewing the cover page. 

801 

802 Returns: 

803 True if on the cover page 

804 """ 

805 return self._on_cover_page 

806 

807 def jump_to_cover(self) -> Optional[Page]: 

808 """ 

809 Jump to the cover page if one exists. 

810 

811 Returns: 

812 Cover page or None if no cover exists 

813 """ 

814 if not self._has_cover: 814 ↛ 815line 814 didn't jump to line 815 because the condition on line 814 was never true

815 return None 

816 

817 self._on_cover_page = True 

818 self._notify_position_changed() 

819 return self.get_current_page() 

820 

821 def get_position_info(self) -> Dict[str, Any]: 

822 """ 

823 Get detailed information about the current position. 

824 

825 Returns: 

826 Dictionary with position details 

827 """ 

828 current_chapter = self.get_current_chapter() 

829 font_family = self.get_font_family() 

830 

831 return { 

832 'position': self.current_position.to_dict(), 

833 'on_cover': self._on_cover_page, 

834 'has_cover': self._has_cover, 

835 'chapter': { 

836 'title': current_chapter.title if current_chapter else None, 

837 'level': current_chapter.level if current_chapter else None, 

838 'index': current_chapter.block_index if current_chapter else None 

839 }, 

840 'progress': self.get_reading_progress(), 

841 'font_scale': self.font_scale, 

842 'font_family': font_family.value if font_family else None, 

843 'page_size': self.page_size 

844 } 

845 

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

847 """ 

848 Get cache statistics for debugging/monitoring. 

849 

850 Returns: 

851 Dictionary with cache statistics 

852 """ 

853 return self.renderer.get_cache_stats() 

854 

855 def shutdown(self): 

856 """ 

857 Shutdown the ereader manager and clean up resources. 

858 Call this when the application is closing. 

859 """ 

860 # Save current position 

861 self.bookmark_manager.save_reading_position(self.current_position) 

862 

863 # Shutdown renderer and buffer 

864 self.renderer.shutdown() 

865 

866 def __del__(self): 

867 """Cleanup on destruction""" 

868 self.shutdown() 

869 

870 

871# Convenience function for quick setup 

872def create_ereader_manager(blocks: List[Block], 

873 page_size: Tuple[int, int], 

874 document_id: str = "default", 

875 **kwargs) -> EreaderLayoutManager: 

876 """ 

877 Convenience function to create an ereader manager with sensible defaults. 

878 

879 Args: 

880 blocks: Document blocks to render 

881 page_size: Page size (width, height) in pixels 

882 document_id: Unique identifier for the document 

883 **kwargs: Additional arguments passed to EreaderLayoutManager 

884 

885 Returns: 

886 Configured EreaderLayoutManager instance 

887 """ 

888 return EreaderLayoutManager(blocks, page_size, document_id, **kwargs)