feat(ereader): wire highlighting into EreaderLayoutManager (R7)

core/highlight.py was 249 lines of fully implemented, fully tested code
that nothing could reach. EreaderLayoutManager had no highlight API, so
highlighting was not available through the library's own interface - it
was tested in isolation and otherwise dead.

Adds to the manager:

    highlight_point(point, color, note, tags)   tap to highlight a word
    highlight_range(start, end, ...)            drag to highlight a span
    remove_highlight(id) / clear_highlights()
    list_highlights() / get_highlights_for_current_page()

Highlights default to the bookmarks directory, so a document's reading
state lives in one place rather than two.

A Highlight carried only pixel bounds, which describe the one rendering
they were taken from: change the font scale or page size and they no
longer point at anything. Highlight now also records the
RenderingPosition of the page it was made on, and page association goes
through that instead of through bounds overlap. The field is optional and
read with .get, so existing stores load unchanged - they simply never
match a page, which is the honest answer for a highlight whose only
anchor is stale pixels.

Also removes the persistence duplication the review called out.
BookmarkManager and HighlightManager each had their own copy of "make the
directory, read the file, swallow and print on failure". Both now use
core/persistence.py, which logs with exc_info instead of printing and
catches specific exceptions rather than bare Exception. File names and
formats are unchanged, so nothing needs migrating.

Tests cover point and range highlighting, colour/note/tag round trips,
misses returning None, page scoping across navigation, persistence across
a restart, and that a corrupt highlight file does not stop a book from
opening.

902 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 13:18:14 +02:00
co-authored by Claude Opus 5
parent bcae45a023
commit 8746d3f549
4 changed files with 386 additions and 65 deletions
+168
View File
@@ -0,0 +1,168 @@
"""
Tests for the highlight API on EreaderLayoutManager (R7).
core/highlight.py was fully implemented and tested but unreachable: the manager
had no highlight API, so highlighting could not be used through the library's
own interface. These tests cover the wiring, not the dataclass - that is
tests/core/test_highlight.py.
"""
import pytest
from pyWebLayout.core.highlight import Highlight, HighlightColor
from pyWebLayout.io.readers.html_extraction import parse_html_string
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager
@pytest.fixture
def manager(tmp_path):
blocks = parse_html_string(
"<p>" + " ".join(f"word{i}" for i in range(300)) + "</p>")
manager = EreaderLayoutManager(blocks, page_size=(400, 600),
document_id="highlights",
bookmarks_dir=str(tmp_path))
yield manager
manager.shutdown()
def text_points(page, limit=None):
"""Points on the rendered page that land on a text object."""
found = []
for y in range(0, 120, 2):
for x in range(0, 400, 2):
result = page.query_point((x, y))
if result is not None and result.object_type == "text" and result.text:
found.append((x, y))
if limit and len(found) >= limit:
return found
return found
@pytest.fixture
def point_on_text(manager):
page = manager.get_current_page()
page.render()
return text_points(page, limit=1)[0]
class TestHighlightPoint:
def test_highlighting_a_word_returns_a_stored_highlight(self, manager, point_on_text):
highlight = manager.highlight_point(point_on_text)
assert isinstance(highlight, Highlight)
assert highlight.text
assert manager.list_highlights() == [highlight]
def test_colour_note_and_tags_are_kept(self, manager, point_on_text):
highlight = manager.highlight_point(
point_on_text, color=HighlightColor.GREEN.value,
note="a note", tags=["review"])
assert highlight.color == HighlightColor.GREEN.value
assert highlight.note == "a note"
assert highlight.tags == ["review"]
def test_highlighting_empty_space_returns_none(self, manager):
manager.get_current_page().render()
assert manager.highlight_point((399, 599)) is None
assert manager.list_highlights() == []
def test_the_originating_position_is_recorded(self, manager, point_on_text):
highlight = manager.highlight_point(point_on_text)
assert highlight.position == manager.current_position.to_dict()
class TestHighlightRange:
def test_a_selection_spans_multiple_words(self, manager):
page = manager.get_current_page()
page.render()
points = text_points(page)
highlight = manager.highlight_range(points[0], points[-1])
assert highlight is not None
assert len(highlight.text.split()) > 1
assert len(highlight.bounds) > 1
def test_a_selection_hitting_no_text_returns_none(self, manager):
manager.get_current_page().render()
assert manager.highlight_range((398, 596), (399, 599)) is None
class TestHighlightsAreScopedToTheirPage:
def test_current_page_highlights_do_not_leak_across_pages(self, manager, point_on_text):
manager.highlight_point(point_on_text)
assert len(manager.get_highlights_for_current_page()) == 1
manager.next_page()
assert manager.get_highlights_for_current_page() == []
assert len(manager.list_highlights()) == 1, "still in the document, just not here"
def test_returning_to_the_page_finds_it_again(self, manager, point_on_text):
highlight = manager.highlight_point(point_on_text)
manager.next_page()
manager.previous_page()
assert manager.get_highlights_for_current_page() == [highlight]
class TestPersistence:
def test_highlights_survive_a_restart(self, manager, point_on_text, tmp_path):
highlight = manager.highlight_point(point_on_text, note="kept")
manager.shutdown()
reopened = EreaderLayoutManager(
manager.blocks, page_size=(400, 600), document_id="highlights",
bookmarks_dir=str(tmp_path))
try:
restored = reopened.list_highlights()
assert len(restored) == 1
assert restored[0].id == highlight.id
assert restored[0].note == "kept"
assert restored[0].position == highlight.position
finally:
reopened.shutdown()
def test_highlights_share_the_bookmarks_directory_by_default(self, manager,
point_on_text, tmp_path):
manager.highlight_point(point_on_text)
assert (tmp_path / "highlights_highlights.json").exists()
def test_removing_a_highlight_persists(self, manager, point_on_text, tmp_path):
highlight = manager.highlight_point(point_on_text)
assert manager.remove_highlight(highlight.id) is True
assert manager.remove_highlight(highlight.id) is False
reopened = EreaderLayoutManager(
manager.blocks, page_size=(400, 600), document_id="highlights",
bookmarks_dir=str(tmp_path))
try:
assert reopened.list_highlights() == []
finally:
reopened.shutdown()
def test_clear_removes_everything(self, manager, point_on_text):
manager.highlight_point(point_on_text)
manager.clear_highlights()
assert manager.list_highlights() == []
def test_a_corrupt_store_does_not_stop_the_book_opening(self, tmp_path):
(tmp_path / "broken_highlights.json").write_text("{not json")
blocks = parse_html_string("<p>hello world</p>")
manager = EreaderLayoutManager(blocks, page_size=(400, 600),
document_id="broken",
bookmarks_dir=str(tmp_path))
try:
assert manager.list_highlights() == []
assert manager.get_current_page() is not None
finally:
manager.shutdown()