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
« 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.
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"""
9from __future__ import annotations
10from typing import List, Dict, Optional, Tuple, Any, Callable
11import logging
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_
27logger = logging.getLogger(__name__)
30class BookmarkManager:
31 """
32 Manages bookmarks and reading position persistence for ereader applications.
33 """
35 def __init__(self, document_id: str, bookmarks_dir: str = "bookmarks"):
36 """
37 Initialize bookmark manager.
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)
46 self.bookmarks_file = self.bookmarks_dir / f"{document_id}_bookmarks.json"
47 self.position_file = self.bookmarks_dir / f"{document_id}_position.json"
49 self._bookmarks: Dict[str, RenderingPosition] = {}
50 self._load_bookmarks()
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 = {}
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 })
72 def add_bookmark(self, name: str, position: RenderingPosition):
73 """
74 Add a bookmark at the given position.
76 Args:
77 name: Bookmark name
78 position: Position to bookmark
79 """
80 self._bookmarks[name] = position
81 self._save_bookmarks()
83 def remove_bookmark(self, name: str) -> bool:
84 """
85 Remove a bookmark.
87 Args:
88 name: Bookmark name to remove
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
99 def get_bookmark(self, name: str) -> Optional[RenderingPosition]:
100 """
101 Get a bookmark position.
103 Args:
104 name: Bookmark name
106 Returns:
107 Bookmark position or None if not found
108 """
109 return self._bookmarks.get(name)
111 def list_bookmarks(self) -> List[Tuple[str, RenderingPosition]]:
112 """
113 Get all bookmarks.
115 Returns:
116 List of (name, position) tuples
117 """
118 return list(self._bookmarks.items())
120 def save_reading_position(self, position: RenderingPosition):
121 """
122 Save the current reading position.
124 Args:
125 position: Current reading position
126 """
127 write_json(self.position_file, position.to_dict())
129 def load_reading_position(self) -> Optional[RenderingPosition]:
130 """
131 Load the last reading position.
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
147class EreaderLayoutManager:
148 """
149 High-level ereader layout manager providing a complete interface for ereader applications.
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 """
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.
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
186 # Initialize page style
187 if page_style is None:
188 page_style = PageStyle()
189 self.page_style = page_style
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)
198 # Current state
199 self.current_position = RenderingPosition()
200 self.font_scale = 1.0
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
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
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
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
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
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.
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.
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.
240 Args:
241 max_words: Maximum distinct words to preload.
242 budget_bytes: Bytes of glyph cache to fill. Defaults to half the budget.
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
251 override = getattr(self.renderer.layouter, 'font_family_override', None)
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
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
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))
285 return prewarm_text_caches(entries, budget_bytes=budget_bytes,
286 max_words=max_words)
288 def set_position_changed_callback(
289 self, callback: Callable[[RenderingPosition], None]):
290 """Set callback for position changes"""
291 self.position_changed_callback = callback
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
298 def _detect_cover(self) -> bool:
299 """
300 Detect if the document has a cover page.
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)
306 Returns:
307 True if a cover page should be rendered
308 """
309 if not self.blocks:
310 return False
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
317 return False
319 def _render_cover_page(self) -> Page:
320 """
321 Render a dedicated cover page.
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.
326 Returns:
327 Rendered cover page
328 """
329 # Create a new page for the cover
330 page = Page(self.page_size, self.page_style)
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
336 cover_image_block = self.blocks[0]
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
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 )
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")
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}")
359 return page
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)
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)
372 # Auto-save reading position
373 self.bookmark_manager.save_reading_position(self.current_position)
375 def get_current_page(self) -> Page:
376 """
377 Get the page at the current reading position.
379 If on the cover page, returns the rendered cover.
380 Otherwise, returns the regular content page.
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()
389 page, _ = self.renderer.render_page(self.current_position, self.font_scale)
390 return page
392 def next_page(self) -> Optional[Page]:
393 """
394 Advance to the next page.
396 If currently on the cover page, advances to the first content page.
397 Otherwise, advances to the next content page.
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()
413 # Save current position to history before moving forward
414 self._add_to_history(self.current_position, self.font_scale)
416 page, next_position = self.renderer.render_page(
417 self.current_position, self.font_scale)
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()
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()
440 return None # At end of document
442 def previous_page(self) -> Optional[Page]:
443 """
444 Go to the previous page.
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.
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()
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
469 if self._is_at_beginning():
470 return None
472 # Fast path: Check if we have this position in history
473 previous_position = self._get_from_history(self.current_position, self.font_scale)
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()
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)
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)
490 self.current_position = start_position
491 self._notify_position_changed()
492 return page
494 return None # At beginning of document
496 def _is_at_beginning(self) -> bool:
497 """
498 Check if we're at the beginning of the document content.
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
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)
510 def jump_to_position(self, position: RenderingPosition) -> Page:
511 """
512 Jump to a specific position in the document.
514 Args:
515 position: Position to jump to
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()
525 def jump_to_chapter(self, chapter_title: str) -> Optional[Page]:
526 """
527 Jump to a specific chapter by title.
529 Args:
530 chapter_title: Title of the chapter to jump to
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
540 def jump_to_chapter_index(self, chapter_index: int) -> Optional[Page]:
541 """
542 Jump to a chapter by index.
544 Args:
545 chapter_index: Index of the chapter (0-based)
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
555 def _add_to_history(self, position: RenderingPosition, font_scale: float):
556 """
557 Add a page position to the navigation history.
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:
568 self._page_history.append((position.copy(), font_scale))
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)
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.
581 Searches backward through history to find the last position that
582 comes before the current position at the same font scale.
584 Args:
585 current_position: Current page position
586 current_font_scale: Current font scale
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]
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
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)):
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()
612 return None
614 def _clear_history(self):
615 """Clear the page navigation history."""
616 self._page_history.clear()
618 def set_font_scale(self, scale: float) -> Page:
619 """
620 Change the font scale and re-render current page.
622 Clears page history since font changes invalidate all cached positions.
624 Args:
625 scale: Font scaling factor (1.0 = normal, 2.0 = double size, etc.)
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
636 return self.get_current_page()
638 def get_font_scale(self) -> float:
639 """Get the current font scale"""
640 return self.font_scale
642 def set_font_family(self, family: Optional[BundledFont]) -> Page:
643 """
644 Change the font family and re-render current page.
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.
650 Args:
651 family: Bundled font family to use (SANS, SERIF, MONOSPACE, or None for original fonts)
653 Returns:
654 Re-rendered page with new font family
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)
665 # Clear history since font changes invalidate all cached positions
666 self._clear_history()
668 return self.get_current_page()
670 def get_font_family(self) -> Optional[BundledFont]:
671 """
672 Get the current font family override.
674 Returns:
675 Current font family (SANS, SERIF, MONOSPACE) or None if using original fonts
676 """
677 return self.renderer.get_font_family()
679 def increase_line_spacing(self, amount: int = 2) -> Page:
680 """
681 Increase line spacing and re-render current page.
683 Clears page history since spacing changes invalidate all cached positions.
685 Args:
686 amount: Pixels to add to line spacing (default: 2)
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()
697 def decrease_line_spacing(self, amount: int = 2) -> Page:
698 """
699 Decrease line spacing and re-render current page.
701 Clears page history since spacing changes invalidate all cached positions.
703 Args:
704 amount: Pixels to remove from line spacing (default: 2)
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()
715 def increase_inter_block_spacing(self, amount: int = 5) -> Page:
716 """
717 Increase spacing between blocks and re-render current page.
719 Clears page history since spacing changes invalidate all cached positions.
721 Args:
722 amount: Pixels to add to inter-block spacing (default: 5)
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()
733 def decrease_inter_block_spacing(self, amount: int = 5) -> Page:
734 """
735 Decrease spacing between blocks and re-render current page.
737 Clears page history since spacing changes invalidate all cached positions.
739 Args:
740 amount: Pixels to remove from inter-block spacing (default: 5)
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()
752 def increase_word_spacing(self, amount: int = 2) -> Page:
753 """
754 Increase spacing between words and re-render current page.
756 Clears page history since spacing changes invalidate all cached positions.
758 Args:
759 amount: Pixels to add to word spacing (default: 2)
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()
770 def decrease_word_spacing(self, amount: int = 2) -> Page:
771 """
772 Decrease spacing between words and re-render current page.
774 Clears page history since spacing changes invalidate all cached positions.
776 Args:
777 amount: Pixels to remove from word spacing (default: 2)
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()
788 def get_table_of_contents(
789 self) -> List[Tuple[str, HeadingLevel, RenderingPosition]]:
790 """
791 Get the table of contents.
793 Returns:
794 List of (title, level, position) tuples
795 """
796 return self.chapter_navigator.get_table_of_contents()
798 def get_current_chapter(self) -> Optional[ChapterInfo]:
799 """
800 Get information about the current chapter.
802 Returns:
803 Current chapter info or None if no chapters
804 """
805 return self.chapter_navigator.get_current_chapter(self.current_position)
807 def add_bookmark(self, name: str) -> bool:
808 """
809 Add a bookmark at the current position.
811 Args:
812 name: Bookmark name
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
823 def remove_bookmark(self, name: str) -> bool:
824 """
825 Remove a bookmark.
827 Args:
828 name: Bookmark name
830 Returns:
831 True if bookmark was removed
832 """
833 return self.bookmark_manager.remove_bookmark(name)
835 def jump_to_bookmark(self, name: str) -> Optional[Page]:
836 """
837 Jump to a bookmark.
839 Args:
840 name: Bookmark name
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
850 def list_bookmarks(self) -> List[Tuple[str, RenderingPosition]]:
851 """
852 Get all bookmarks.
854 Returns:
855 List of (name, position) tuples
856 """
857 return self.bookmark_manager.list_bookmarks()
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 # ------------------------------------------------------------------
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.
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
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
890 return self._store_highlight(result, color, note, tags)
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.
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
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
915 return self._store_highlight(selection, color, note, tags)
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
925 def remove_highlight(self, highlight_id: str) -> bool:
926 """
927 Remove a highlight.
929 Args:
930 highlight_id: ID of the highlight to remove
932 Returns:
933 True if it existed and was removed
934 """
935 return self.highlight_manager.remove_highlight(highlight_id)
937 def list_highlights(self) -> List[Highlight]:
938 """Get every highlight in this document."""
939 return self.highlight_manager.list_highlights()
941 def get_highlights_for_current_page(self) -> List[Highlight]:
942 """
943 Get the highlights made on the page currently being displayed.
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]
953 def clear_highlights(self) -> None:
954 """Remove every highlight in this document."""
955 self.highlight_manager.clear_all()
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 # ------------------------------------------------------------------
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
976 def handle_hover(self, point: Tuple[int, int]) -> Optional[Image_.Image]:
977 """
978 Update hover feedback for a pointer at `point`.
980 Args:
981 point: (x, y) in page coordinates
983 Returns:
984 A re-rendered frame if the hover state changed, else None.
985 """
986 return self._interaction_state().update_hover(point)
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`.
992 Args:
993 point: (x, y) in page coordinates
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)
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.
1005 Args:
1006 point: (x, y) in page coordinates
1008 Returns:
1009 (frame, callback_result). Both are None if no element was pressed.
1010 """
1011 return self._interaction_state().handle_mouse_up(point)
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()
1018 def get_reading_progress(self) -> float:
1019 """
1020 Get reading progress as a percentage.
1022 Returns:
1023 Progress from 0.0 to 1.0
1024 """
1025 if not self.blocks:
1026 return 0.0
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)
1033 return current_block / max(1, total_blocks - 1)
1035 def has_cover(self) -> bool:
1036 """
1037 Check if the document has a cover page.
1039 Returns:
1040 True if a cover page is available
1041 """
1042 return self._has_cover
1044 def is_on_cover(self) -> bool:
1045 """
1046 Check if currently viewing the cover page.
1048 Returns:
1049 True if on the cover page
1050 """
1051 return self._on_cover_page
1053 def jump_to_cover(self) -> Optional[Page]:
1054 """
1055 Jump to the cover page if one exists.
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
1063 self._on_cover_page = True
1064 self._notify_position_changed()
1065 return self.get_current_page()
1067 def get_position_info(self) -> Dict[str, Any]:
1068 """
1069 Get detailed information about the current position.
1071 Returns:
1072 Dictionary with position details
1073 """
1074 current_chapter = self.get_current_chapter()
1075 font_family = self.get_font_family()
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 }
1092 def get_cache_stats(self) -> Dict[str, Any]:
1093 """
1094 Get cache statistics for debugging/monitoring.
1096 Returns:
1097 Dictionary with cache statistics
1098 """
1099 return self.renderer.get_cache_stats()
1101 def shutdown(self):
1102 """
1103 Shutdown the ereader manager and clean up resources.
1104 Call this when the application is closing.
1106 Idempotent: calling it twice saves the position once.
1107 """
1108 if getattr(self, '_shutdown_done', False):
1109 return
1110 self._shutdown_done = True
1112 # Save current position
1113 self.bookmark_manager.save_reading_position(self.current_position)
1115 # Release cached pages
1116 self.renderer.shutdown()
1118 def __del__(self):
1119 """
1120 Best-effort cleanup for callers that never called shutdown().
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
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.
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
1146 Returns:
1147 Configured EreaderLayoutManager instance
1148 """
1149 return EreaderLayoutManager(blocks, page_size, document_id, **kwargs)