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

370 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-08 20:34 +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 logging 

12 

13from .ereader_layout import RenderingPosition, ChapterNavigator, ChapterInfo 

14from .page_buffer import BufferedPageRenderer 

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

16from pyWebLayout.concrete.page import Page 

17from pyWebLayout.concrete.image import RenderableImage 

18from pyWebLayout.style.page_style import PageStyle 

19from pyWebLayout.style.fonts import BundledFont 

20from pyWebLayout.layout.document_layouter import image_layouter 

21from pyWebLayout.core.highlight import Highlight, HighlightColor, HighlightManager, \ 

22 create_highlight_from_query_result 

23from pyWebLayout.core.persistence import ensure_dir, read_json, write_json 

24from pyWebLayout.concrete.interaction_handler import InteractionStateManager 

25from PIL import Image as Image_ 

26 

27logger = logging.getLogger(__name__) 

28 

29 

30class BookmarkManager: 

31 """ 

32 Manages bookmarks and reading position persistence for ereader applications. 

33 """ 

34 

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

36 """ 

37 Initialize bookmark manager. 

38 

39 Args: 

40 document_id: Unique identifier for the document 

41 bookmarks_dir: Directory to store bookmark files 

42 """ 

43 self.document_id = document_id 

44 self.bookmarks_dir = ensure_dir(bookmarks_dir) 

45 

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

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

48 

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

50 self._load_bookmarks() 

51 

52 def _load_bookmarks(self): 

53 """Load bookmarks from file""" 

54 data = read_json(self.bookmarks_file, {}) 

55 try: 

56 self._bookmarks = { 

57 name: RenderingPosition.from_dict(pos_data) 

58 for name, pos_data in data.items() 

59 } 

60 except (AttributeError, TypeError, KeyError): 

61 logger.warning("Bookmark file %s is not in the expected shape; ignoring it", 

62 self.bookmarks_file, exc_info=True) 

63 self._bookmarks = {} 

64 

65 def _save_bookmarks(self): 

66 """Save bookmarks to file""" 

67 write_json(self.bookmarks_file, { 

68 name: position.to_dict() 

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

70 }) 

71 

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

73 """ 

74 Add a bookmark at the given position. 

75 

76 Args: 

77 name: Bookmark name 

78 position: Position to bookmark 

79 """ 

80 self._bookmarks[name] = position 

81 self._save_bookmarks() 

82 

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

84 """ 

85 Remove a bookmark. 

86 

87 Args: 

88 name: Bookmark name to remove 

89 

90 Returns: 

91 True if bookmark was removed, False if not found 

92 """ 

93 if name in self._bookmarks: 

94 del self._bookmarks[name] 

95 self._save_bookmarks() 

96 return True 

97 return False 

98 

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

100 """ 

101 Get a bookmark position. 

102 

103 Args: 

104 name: Bookmark name 

105 

106 Returns: 

107 Bookmark position or None if not found 

108 """ 

109 return self._bookmarks.get(name) 

110 

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

112 """ 

113 Get all bookmarks. 

114 

115 Returns: 

116 List of (name, position) tuples 

117 """ 

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

119 

120 def save_reading_position(self, position: RenderingPosition): 

121 """ 

122 Save the current reading position. 

123 

124 Args: 

125 position: Current reading position 

126 """ 

127 write_json(self.position_file, position.to_dict()) 

128 

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

130 """ 

131 Load the last reading position. 

132 

133 Returns: 

134 Last reading position or None if not found 

135 """ 

136 data = read_json(self.position_file, None) 

137 if data is None: 

138 return None 

139 try: 

140 return RenderingPosition.from_dict(data) 

141 except (TypeError, KeyError): 

142 logger.warning("Position file %s is not in the expected shape; ignoring it", 

143 self.position_file, exc_info=True) 

144 return None 

145 

146 

147class EreaderLayoutManager: 

148 """ 

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

150 

151 Features: 

152 - Sub-second page rendering with intelligent buffering 

153 - Font scaling support 

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

155 - Chapter navigation 

156 - Bookmark management 

157 - Position persistence 

158 - Progress tracking 

159 """ 

160 

161 def __init__(self, 

162 blocks: List[Block], 

163 page_size: Tuple[int, int], 

164 document_id: str = "default", 

165 buffer_size: int = 5, 

166 page_style: Optional[PageStyle] = None, 

167 bookmarks_dir: str = "bookmarks", 

168 highlights_dir: Optional[str] = None): 

169 """ 

170 Initialize the ereader layout manager. 

171 

172 Args: 

173 blocks: Document blocks to render 

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

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

176 buffer_size: Number of pages to cache in each direction 

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

178 bookmarks_dir: Directory to store bookmark files 

179 highlights_dir: Directory to store highlights. Defaults to 

180 bookmarks_dir, so a document's reading state lives in one place. 

181 """ 

182 self.blocks = blocks 

183 self.page_size = page_size 

184 self.document_id = document_id 

185 

186 # Initialize page style 

187 if page_style is None: 

188 page_style = PageStyle() 

189 self.page_style = page_style 

190 

191 # Initialize core components 

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

193 self.chapter_navigator = ChapterNavigator(blocks) 

194 self.bookmark_manager = BookmarkManager(document_id, bookmarks_dir) 

195 self.highlight_manager = HighlightManager( 

196 document_id, highlights_dir if highlights_dir is not None else bookmarks_dir) 

197 

198 # Current state 

199 self.current_position = RenderingPosition() 

200 self.font_scale = 1.0 

201 

202 # Cover page handling 

203 self._has_cover = self._detect_cover() 

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

205 

206 # Page position history for fast backward navigation 

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

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

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

210 

211 # Load last reading position if available 

212 saved_position = self.bookmark_manager.load_reading_position() 

213 if saved_position: 

214 self.current_position = saved_position 

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

216 

217 # Pointer interaction state, rebound whenever the displayed page changes 

218 self._interaction_state_manager: Optional[InteractionStateManager] = None 

219 self._interaction_page: Optional[Page] = None 

220 

221 # Callbacks for UI updates 

222 self.position_changed_callback: Optional[Callable[[ 

223 RenderingPosition], None]] = None 

224 self.chapter_changed_callback: Optional[Callable[[ 

225 Optional[ChapterInfo]], None]] = None 

226 

227 def prewarm_caches(self, max_words: int = 2000, 

228 budget_bytes: Optional[int] = None) -> Tuple[int, int]: 

229 """ 

230 Preload the text caches with this document's most frequent words. 

231 

232 Counts how often each word occurs in the book and rasterises the most 

233 common ones ahead of time, so that the work lands at open time rather than 

234 on the first page turns. Entries are seeded with their document frequency, 

235 which is what keeps them resident under usage-ranked eviction. 

236 

237 Safe to call again after a font change; the fonts differ, so the new 

238 entries simply take their place in the eviction order alongside the old. 

239 

240 Args: 

241 max_words: Maximum distinct words to preload. 

242 budget_bytes: Bytes of glyph cache to fill. Defaults to half the budget. 

243 

244 Returns: 

245 Tuple of (words preloaded, bytes preloaded). 

246 """ 

247 from collections import Counter 

248 from pyWebLayout.concrete.text import prewarm_text_caches 

249 from .ereader_layout import FontScaler 

250 

251 override = getattr(self.renderer.layouter, 'font_family_override', None) 

252 

253 # Count by (style, text): the same word in a heading and in body text is a 

254 # different rasterisation, and both are worth counting separately. 

255 counts: Dict[Tuple[int, str], int] = Counter() 

256 styles: Dict[int, Any] = {} 

257 for block in self.blocks: 

258 words = getattr(block, '_words', None) 

259 if not words: 

260 continue 

261 for word in words: 

262 style = word.style 

263 if style is None: 

264 continue 

265 key = id(style) 

266 styles.setdefault(key, style) 

267 counts[(key, word.text)] += 1 

268 

269 # Resolve each distinct style once through the same scaling the layouter 

270 # applies, so the preloaded keys match what rendering will look up. 

271 scaled: Dict[int, Any] = {} 

272 for key, style in styles.items(): 

273 try: 

274 scaled[key] = FontScaler.scale_font(style, self.font_scale, override) 

275 except Exception: 

276 continue 

277 

278 entries = [] 

279 for (style_key, text), count in counts.items(): 

280 font = scaled.get(style_key) 

281 if font is None: 

282 continue 

283 entries.append((font.font, text, font.colour, count)) 

284 

285 return prewarm_text_caches(entries, budget_bytes=budget_bytes, 

286 max_words=max_words) 

287 

288 def set_position_changed_callback( 

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

290 """Set callback for position changes""" 

291 self.position_changed_callback = callback 

292 

293 def set_chapter_changed_callback( 

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

295 """Set callback for chapter changes""" 

296 self.chapter_changed_callback = callback 

297 

298 def _detect_cover(self) -> bool: 

299 """ 

300 Detect if the document has a cover page. 

301 

302 A cover is detected if: 

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

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

305 

306 Returns: 

307 True if a cover page should be rendered 

308 """ 

309 if not self.blocks: 

310 return False 

311 

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

313 first_block = self.blocks[0] 

314 if isinstance(first_block, Image): 

315 return True 

316 

317 return False 

318 

319 def _render_cover_page(self) -> Page: 

320 """ 

321 Render a dedicated cover page. 

322 

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

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

325 

326 Returns: 

327 Rendered cover page 

328 """ 

329 # Create a new page for the cover 

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

331 

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

333 # No cover image, return blank page 

334 return page 

335 

336 cover_image_block = self.blocks[0] 

337 

338 # Use the image layouter to render the cover image 

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

340 try: 

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

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

343 

344 # Layout the image on the page 

345 success = image_layouter( 

346 image=cover_image_block, 

347 page=page, 

348 max_width=max_width, 

349 max_height=max_height 

350 ) 

351 

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

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

354 

355 except Exception as e: 

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

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

358 

359 return page 

360 

361 def _notify_position_changed(self): 

362 """Notify UI of position change""" 

363 if self.position_changed_callback: 

364 self.position_changed_callback(self.current_position) 

365 

366 # Check if chapter changed 

367 current_chapter = self.chapter_navigator.get_current_chapter( 

368 self.current_position) 

369 if self.chapter_changed_callback: 

370 self.chapter_changed_callback(current_chapter) 

371 

372 # Auto-save reading position 

373 self.bookmark_manager.save_reading_position(self.current_position) 

374 

375 def get_current_page(self) -> Page: 

376 """ 

377 Get the page at the current reading position. 

378 

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

380 Otherwise, returns the regular content page. 

381 

382 Returns: 

383 Rendered page 

384 """ 

385 # Check if we're on the cover page 

386 if self._on_cover_page and self._has_cover: 

387 return self._render_cover_page() 

388 

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

390 return page 

391 

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

393 """ 

394 Advance to the next page. 

395 

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

397 Otherwise, advances to the next content page. 

398 

399 Returns: 

400 Next page or None if at end of document 

401 """ 

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

403 if self._on_cover_page and self._has_cover: 

404 self._on_cover_page = False 

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

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

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

408 else: 

409 self.current_position = RenderingPosition() 

410 self._notify_position_changed() 

411 return self.get_current_page() 

412 

413 # Save current position to history before moving forward 

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

415 

416 page, next_position = self.renderer.render_page( 

417 self.current_position, self.font_scale) 

418 

419 # Check if we made progress 

420 if next_position != self.current_position: 

421 self.current_position = next_position 

422 self._notify_position_changed() 

423 return self.get_current_page() 

424 

425 # No progress. That is the correct answer only at the end of the 

426 # document; anywhere else a block has failed to lay out and would trap 

427 # the reader on this page. Skipping the block costs one block, not the 

428 # rest of the book. 

429 if self.current_position.block_index < len(self.blocks): 

430 logger.error( 

431 "Block %d made no layout progress; skipping it. This is a layout " 

432 "bug - the block placed nothing and reported no resume point.", 

433 self.current_position.block_index) 

434 self.current_position = RenderingPosition( 

435 chapter_index=self.current_position.chapter_index, 

436 block_index=self.current_position.block_index + 1) 

437 self._notify_position_changed() 

438 return self.get_current_page() 

439 

440 return None # At end of document 

441 

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

443 """ 

444 Go to the previous page. 

445 

446 Uses cached page history for instant navigation when available, 

447 falls back to iterative refinement algorithm when needed. 

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

449 

450 Returns: 

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

452 """ 

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

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

455 self._on_cover_page = True 

456 # Restore the canonical cover position. Being on the cover must have a 

457 # single representation: a fresh load sits at block 0 with the cover 

458 # showing, so returning to the cover has to land there too. Leaving the 

459 # position at the first content block saves a position that reopens past 

460 # the cover, silently losing it. 

461 self.current_position = RenderingPosition() 

462 self._notify_position_changed() 

463 return self.get_current_page() 

464 

465 # Can't go before the cover 

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

467 return None 

468 

469 if self._is_at_beginning(): 

470 return None 

471 

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

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

474 

475 if previous_position is not None: 

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

477 self.current_position = previous_position 

478 self._notify_position_changed() 

479 return self.get_current_page() 

480 

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

482 # This uses the iterative refinement algorithm we just fixed 

483 page, start_position = self.renderer.render_page_backward( 

484 self.current_position, self.font_scale) 

485 

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

487 # Save this calculated position to history for future use 

488 self._add_to_history(start_position, self.font_scale) 

489 

490 self.current_position = start_position 

491 self._notify_position_changed() 

492 return page 

493 

494 return None # At beginning of document 

495 

496 def _is_at_beginning(self) -> bool: 

497 """ 

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

499 

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

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

502 """ 

503 # Determine the first content block index 

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

505 

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

507 self.current_position.block_index == first_content_block and 

508 self.current_position.word_index == 0) 

509 

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

511 """ 

512 Jump to a specific position in the document. 

513 

514 Args: 

515 position: Position to jump to 

516 

517 Returns: 

518 Page at the new position 

519 """ 

520 self.current_position = position 

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

522 self._notify_position_changed() 

523 return self.get_current_page() 

524 

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

526 """ 

527 Jump to a specific chapter by title. 

528 

529 Args: 

530 chapter_title: Title of the chapter to jump to 

531 

532 Returns: 

533 Page at chapter start or None if chapter not found 

534 """ 

535 position = self.chapter_navigator.get_chapter_position(chapter_title) 

536 if position: 

537 return self.jump_to_position(position) 

538 return None 

539 

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

541 """ 

542 Jump to a chapter by index. 

543 

544 Args: 

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

546 

547 Returns: 

548 Page at chapter start or None if index invalid 

549 """ 

550 chapters = self.chapter_navigator.chapters 

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

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

553 return None 

554 

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

556 """ 

557 Add a page position to the navigation history. 

558 

559 Args: 

560 position: The page start position to remember 

561 font_scale: The font scale at this position 

562 """ 

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

564 if not self._page_history or \ 

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

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

567 

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

569 

570 # Trim history if it exceeds max size 

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

572 self._page_history.pop(0) 

573 

574 def _get_from_history( 

575 self, 

576 current_position: RenderingPosition, 

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

578 """ 

579 Get the previous page position from history. 

580 

581 Searches backward through history to find the last position that 

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

583 

584 Args: 

585 current_position: Current page position 

586 current_font_scale: Current font scale 

587 

588 Returns: 

589 Previous page position or None if not found in history 

590 """ 

591 # Search backward through history 

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

593 hist_position, hist_font_scale = self._page_history[i] 

594 

595 # Must match font scale 

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

597 continue 

598 

599 # Must be before current position 

600 if (hist_position.chapter_index < current_position.chapter_index or 

601 (hist_position.chapter_index == current_position.chapter_index and 

602 hist_position.block_index < current_position.block_index) or 

603 (hist_position.chapter_index == current_position.chapter_index and 

604 hist_position.block_index == current_position.block_index and 

605 hist_position.word_index < current_position.word_index)): 

606 

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

608 # since we're navigating backward 

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

610 return hist_position.copy() 

611 

612 return None 

613 

614 def _clear_history(self): 

615 """Clear the page navigation history.""" 

616 self._page_history.clear() 

617 

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

619 """ 

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

621 

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

623 

624 Args: 

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

626 

627 Returns: 

628 Re-rendered page with new font scale 

629 """ 

630 if scale != self.font_scale: 

631 self.font_scale = scale 

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

633 self._clear_history() 

634 # The renderer will handle cache invalidation 

635 

636 return self.get_current_page() 

637 

638 def get_font_scale(self) -> float: 

639 """Get the current font scale""" 

640 return self.font_scale 

641 

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

643 """ 

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

645 

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

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

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

649 

650 Args: 

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

652 

653 Returns: 

654 Re-rendered page with new font family 

655 

656 Example: 

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

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

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

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

661 """ 

662 # Update the renderer's font family 

663 self.renderer.set_font_family(family) 

664 

665 # Clear history since font changes invalidate all cached positions 

666 self._clear_history() 

667 

668 return self.get_current_page() 

669 

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

671 """ 

672 Get the current font family override. 

673 

674 Returns: 

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

676 """ 

677 return self.renderer.get_font_family() 

678 

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

680 """ 

681 Increase line spacing and re-render current page. 

682 

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

684 

685 Args: 

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

687 

688 Returns: 

689 Re-rendered page with increased line spacing 

690 """ 

691 self.page_style.line_spacing += amount 

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

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

694 self._clear_history() # Clear position history 

695 return self.get_current_page() 

696 

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

698 """ 

699 Decrease line spacing and re-render current page. 

700 

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

702 

703 Args: 

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

705 

706 Returns: 

707 Re-rendered page with decreased line spacing 

708 """ 

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

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

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

712 self._clear_history() # Clear position history 

713 return self.get_current_page() 

714 

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

716 """ 

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

718 

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

720 

721 Args: 

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

723 

724 Returns: 

725 Re-rendered page with increased block spacing 

726 """ 

727 self.page_style.inter_block_spacing += amount 

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

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

730 self._clear_history() # Clear position history 

731 return self.get_current_page() 

732 

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

734 """ 

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

736 

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

738 

739 Args: 

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

741 

742 Returns: 

743 Re-rendered page with decreased block spacing 

744 """ 

745 self.page_style.inter_block_spacing = max( 

746 0, self.page_style.inter_block_spacing - amount) 

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

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

749 self._clear_history() # Clear position history 

750 return self.get_current_page() 

751 

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

753 """ 

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

755 

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

757 

758 Args: 

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

760 

761 Returns: 

762 Re-rendered page with increased word spacing 

763 """ 

764 self.page_style.word_spacing += amount 

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

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

767 self._clear_history() # Clear position history 

768 return self.get_current_page() 

769 

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

771 """ 

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

773 

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

775 

776 Args: 

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

778 

779 Returns: 

780 Re-rendered page with decreased word spacing 

781 """ 

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

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

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

785 self._clear_history() # Clear position history 

786 return self.get_current_page() 

787 

788 def get_table_of_contents( 

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

790 """ 

791 Get the table of contents. 

792 

793 Returns: 

794 List of (title, level, position) tuples 

795 """ 

796 return self.chapter_navigator.get_table_of_contents() 

797 

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

799 """ 

800 Get information about the current chapter. 

801 

802 Returns: 

803 Current chapter info or None if no chapters 

804 """ 

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

806 

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

808 """ 

809 Add a bookmark at the current position. 

810 

811 Args: 

812 name: Bookmark name 

813 

814 Returns: 

815 True if bookmark was added successfully 

816 """ 

817 try: 

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

819 return True 

820 except Exception: 

821 return False 

822 

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

824 """ 

825 Remove a bookmark. 

826 

827 Args: 

828 name: Bookmark name 

829 

830 Returns: 

831 True if bookmark was removed 

832 """ 

833 return self.bookmark_manager.remove_bookmark(name) 

834 

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

836 """ 

837 Jump to a bookmark. 

838 

839 Args: 

840 name: Bookmark name 

841 

842 Returns: 

843 Page at bookmark position or None if bookmark not found 

844 """ 

845 position = self.bookmark_manager.get_bookmark(name) 

846 if position: 

847 return self.jump_to_position(position) 

848 return None 

849 

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

851 """ 

852 Get all bookmarks. 

853 

854 Returns: 

855 List of (name, position) tuples 

856 """ 

857 return self.bookmark_manager.list_bookmarks() 

858 

859 # ------------------------------------------------------------------ 

860 # Highlights 

861 # 

862 # A Highlight carries pixel bounds, which belong to the one rendering it 

863 # was taken from: change the font scale or page size and they no longer 

864 # describe anything. Each highlight therefore also records the 

865 # RenderingPosition of the page it was made on, and page association goes 

866 # through that rather than through the bounds. 

867 # ------------------------------------------------------------------ 

868 

869 def highlight_point(self, 

870 point: Tuple[int, int], 

871 color: Tuple[int, int, int, int] = HighlightColor.YELLOW.value, 

872 note: Optional[str] = None, 

873 tags: Optional[List[str]] = None) -> Optional[Highlight]: 

874 """ 

875 Highlight whatever is at a point on the current page. 

876 

877 Args: 

878 point: (x, y) in page coordinates, as delivered by a tap 

879 color: RGBA fill, e.g. one of HighlightColor 

880 note: Optional annotation 

881 tags: Optional categorization tags 

882 

883 Returns: 

884 The stored Highlight, or None if nothing was at that point. 

885 """ 

886 result = self.get_current_page().query_point(point) 

887 if result is None or result.object_type == "empty": 

888 return None 

889 

890 return self._store_highlight(result, color, note, tags) 

891 

892 def highlight_range(self, 

893 start: Tuple[int, int], 

894 end: Tuple[int, int], 

895 color: Tuple[int, int, int, int] = HighlightColor.YELLOW.value, 

896 note: Optional[str] = None, 

897 tags: Optional[List[str]] = None) -> Optional[Highlight]: 

898 """ 

899 Highlight the text between two points on the current page. 

900 

901 Args: 

902 start: (x, y) where the selection began 

903 end: (x, y) where the selection ended 

904 color: RGBA fill, e.g. one of HighlightColor 

905 note: Optional annotation 

906 tags: Optional categorization tags 

907 

908 Returns: 

909 The stored Highlight, or None if the range selected no text. 

910 """ 

911 selection = self.get_current_page().query_range(start, end) 

912 if not selection.results: 

913 return None 

914 

915 return self._store_highlight(selection, color, note, tags) 

916 

917 def _store_highlight(self, result, color, note, tags) -> Highlight: 

918 """Build a Highlight from a query result and persist it.""" 

919 highlight = create_highlight_from_query_result( 

920 result, color=color, note=note, tags=tags, 

921 position=self.current_position.to_dict()) 

922 self.highlight_manager.add_highlight(highlight) 

923 return highlight 

924 

925 def remove_highlight(self, highlight_id: str) -> bool: 

926 """ 

927 Remove a highlight. 

928 

929 Args: 

930 highlight_id: ID of the highlight to remove 

931 

932 Returns: 

933 True if it existed and was removed 

934 """ 

935 return self.highlight_manager.remove_highlight(highlight_id) 

936 

937 def list_highlights(self) -> List[Highlight]: 

938 """Get every highlight in this document.""" 

939 return self.highlight_manager.list_highlights() 

940 

941 def get_highlights_for_current_page(self) -> List[Highlight]: 

942 """ 

943 Get the highlights made on the page currently being displayed. 

944 

945 Matched on the recorded RenderingPosition, so this stays correct across 

946 font changes; highlights saved before the position field existed have 

947 no position and are never matched. 

948 """ 

949 current = self.current_position.to_dict() 

950 return [h for h in self.highlight_manager.list_highlights() 

951 if h.position == current] 

952 

953 def clear_highlights(self) -> None: 

954 """Remove every highlight in this document.""" 

955 self.highlight_manager.clear_all() 

956 

957 # ------------------------------------------------------------------ 

958 # Pointer interaction 

959 # 

960 # Press/hover feedback is state that belongs to one rendered page, so the 

961 # state machine is rebound whenever the displayed page changes. Callers get 

962 # a fresh frame back when something changed visually, and None when nothing 

963 # did - so a UI can skip a redraw it does not need. 

964 # ------------------------------------------------------------------ 

965 

966 def _interaction_state(self) -> InteractionStateManager: 

967 """The state machine for the page currently displayed.""" 

968 page = self.get_current_page() 

969 if self._interaction_page is not page: 

970 if self._interaction_state_manager is not None: 

971 self._interaction_state_manager.reset() 

972 self._interaction_state_manager = InteractionStateManager(page) 

973 self._interaction_page = page 

974 return self._interaction_state_manager 

975 

976 def handle_hover(self, point: Tuple[int, int]) -> Optional[Image_.Image]: 

977 """ 

978 Update hover feedback for a pointer at `point`. 

979 

980 Args: 

981 point: (x, y) in page coordinates 

982 

983 Returns: 

984 A re-rendered frame if the hover state changed, else None. 

985 """ 

986 return self._interaction_state().update_hover(point) 

987 

988 def handle_touch_down(self, point: Tuple[int, int]) -> Optional[Image_.Image]: 

989 """ 

990 Show pressed feedback for whatever interactive element is at `point`. 

991 

992 Args: 

993 point: (x, y) in page coordinates 

994 

995 Returns: 

996 A frame showing the pressed state, or None if nothing interactive 

997 is there. 

998 """ 

999 return self._interaction_state().handle_mouse_down(point) 

1000 

1001 def handle_touch_up(self, point: Tuple[int, int]) -> Tuple[Optional[Image_.Image], Any]: 

1002 """ 

1003 Release the pressed element and run its action. 

1004 

1005 Args: 

1006 point: (x, y) in page coordinates 

1007 

1008 Returns: 

1009 (frame, callback_result). Both are None if no element was pressed. 

1010 """ 

1011 return self._interaction_state().handle_mouse_up(point) 

1012 

1013 def reset_interaction_state(self) -> None: 

1014 """Clear any hover or press feedback, e.g. when the pointer leaves.""" 

1015 if self._interaction_state_manager is not None: 

1016 self._interaction_state_manager.reset() 

1017 

1018 def get_reading_progress(self) -> float: 

1019 """ 

1020 Get reading progress as a percentage. 

1021 

1022 Returns: 

1023 Progress from 0.0 to 1.0 

1024 """ 

1025 if not self.blocks: 

1026 return 0.0 

1027 

1028 # Simple progress calculation based on block index 

1029 # A more sophisticated version would consider word positions 

1030 total_blocks = len(self.blocks) 

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

1032 

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

1034 

1035 def has_cover(self) -> bool: 

1036 """ 

1037 Check if the document has a cover page. 

1038 

1039 Returns: 

1040 True if a cover page is available 

1041 """ 

1042 return self._has_cover 

1043 

1044 def is_on_cover(self) -> bool: 

1045 """ 

1046 Check if currently viewing the cover page. 

1047 

1048 Returns: 

1049 True if on the cover page 

1050 """ 

1051 return self._on_cover_page 

1052 

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

1054 """ 

1055 Jump to the cover page if one exists. 

1056 

1057 Returns: 

1058 Cover page or None if no cover exists 

1059 """ 

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

1061 return None 

1062 

1063 self._on_cover_page = True 

1064 self._notify_position_changed() 

1065 return self.get_current_page() 

1066 

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

1068 """ 

1069 Get detailed information about the current position. 

1070 

1071 Returns: 

1072 Dictionary with position details 

1073 """ 

1074 current_chapter = self.get_current_chapter() 

1075 font_family = self.get_font_family() 

1076 

1077 return { 

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

1079 'on_cover': self._on_cover_page, 

1080 'has_cover': self._has_cover, 

1081 'chapter': { 

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

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

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

1085 }, 

1086 'progress': self.get_reading_progress(), 

1087 'font_scale': self.font_scale, 

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

1089 'page_size': self.page_size 

1090 } 

1091 

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

1093 """ 

1094 Get cache statistics for debugging/monitoring. 

1095 

1096 Returns: 

1097 Dictionary with cache statistics 

1098 """ 

1099 return self.renderer.get_cache_stats() 

1100 

1101 def shutdown(self): 

1102 """ 

1103 Shutdown the ereader manager and clean up resources. 

1104 Call this when the application is closing. 

1105 

1106 Idempotent: calling it twice saves the position once. 

1107 """ 

1108 if getattr(self, '_shutdown_done', False): 

1109 return 

1110 self._shutdown_done = True 

1111 

1112 # Save current position 

1113 self.bookmark_manager.save_reading_position(self.current_position) 

1114 

1115 # Release cached pages 

1116 self.renderer.shutdown() 

1117 

1118 def __del__(self): 

1119 """ 

1120 Best-effort cleanup for callers that never called shutdown(). 

1121 

1122 Finalisers run during interpreter teardown, when modules and globals 

1123 may already be torn down, so this must never raise and must never 

1124 block. Applications should call shutdown() explicitly. 

1125 """ 

1126 try: 

1127 self.shutdown() 

1128 except Exception: 

1129 pass 

1130 

1131 

1132# Convenience function for quick setup 

1133def create_ereader_manager(blocks: List[Block], 

1134 page_size: Tuple[int, int], 

1135 document_id: str = "default", 

1136 **kwargs) -> EreaderLayoutManager: 

1137 """ 

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

1139 

1140 Args: 

1141 blocks: Document blocks to render 

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

1143 document_id: Unique identifier for the document 

1144 **kwargs: Additional arguments passed to EreaderLayoutManager 

1145 

1146 Returns: 

1147 Configured EreaderLayoutManager instance 

1148 """ 

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