Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion run_imposition.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from pyodide.ffi import JsProxy
from imposition.book import Book
from imposition.rendition import Rendition
from imposition.dom import PyodideDOMAdapter

async def main() -> None:
print("Starting Python script")
Expand All @@ -11,7 +12,8 @@ async def main() -> None:
epub_bytes: bytes = epub_bytes_proxy.to_py()

book: Book = Book(epub_bytes)
rendition: Rendition = Rendition(book, "viewer")
dom_adapter = PyodideDOMAdapter()
rendition: Rendition = Rendition(book, dom_adapter, "viewer")

js.window.rendition = rendition

Expand Down
46 changes: 46 additions & 0 deletions src/imposition/dom.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from typing import Protocol, Any, Callable

from js import document
from pyodide.ffi import create_proxy as pyodide_create_proxy
from pyodide.ffi import JsProxy

class DOMElement(Protocol):
"""A protocol for DOM elements."""
style: Any
innerHTML: str
textContent: str
href: str
onclick: Callable[[Any], None]
onload: str
src: str

def appendChild(self, child: "DOMElement") -> None:
...

def setAttribute(self, name: str, value: str) -> None:
...

def preventDefault(self) -> None:
...

class DOMAdapter(Protocol):
"""A protocol for DOM operations."""
def get_element_by_id(self, element_id: str) -> DOMElement:
...

def create_element(self, tag_name: str) -> DOMElement:
...

def create_proxy(self, handler: Callable[..., Any]) -> Callable[..., Any]:
...

class PyodideDOMAdapter:
"""An implementation of the DOMAdapter protocol using Pyodide."""
def get_element_by_id(self, element_id: str) -> JsProxy:
return document.getElementById(element_id)

def create_element(self, tag_name: str) -> JsProxy:
return document.createElement(tag_name)

def create_proxy(self, handler: Callable[..., Any]) -> Callable[..., Any]:
return pyodide_create_proxy(handler)
27 changes: 15 additions & 12 deletions src/imposition/rendition.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Callable, Optional

from js import document
from pyodide.ffi import create_proxy, JsProxy
import xml.etree.ElementTree as ET
import base64
import posixpath
import mimetypes

from .dom import DOMAdapter, DOMElement

if TYPE_CHECKING:
from .book import Book

Expand All @@ -20,21 +20,24 @@ class Rendition:
chapters, and provides navigation between them.
"""

def __init__(self, book: Book, target_id: str) -> None:
def __init__(self, book: Book, dom_adapter: DOMAdapter, target_id: str) -> None:
"""
Initializes the Rendition object.

:param book: An initialized Book object.
:type book: Book
:param dom_adapter: An adapter for DOM operations.
:type dom_adapter: DOMAdapter
:param target_id: The ID of the HTML element where the EPUB content
will be rendered.
:type target_id: str
"""
self.book: Book = book
self.dom_adapter: DOMAdapter = dom_adapter
self.target_id: str = target_id
self.target_element: JsProxy = document.getElementById(self.target_id)
self.target_element: DOMElement = self.dom_adapter.get_element_by_id(self.target_id)
self.current_chapter_index: int = 0
self.iframe: JsProxy = document.createElement('iframe')
self.iframe: DOMElement = self.dom_adapter.create_element('iframe')
self.iframe.style.width = '100%'
self.iframe.style.height = '100%'
self.iframe.style.border = 'none'
Expand All @@ -43,25 +46,25 @@ def display_toc(self) -> None:
"""
Renders the table of contents into the 'toc-container' element.
"""
toc_container: JsProxy = document.getElementById('toc-container')
toc_container: DOMElement = self.dom_adapter.get_element_by_id('toc-container')
toc_container.innerHTML = ''
ul: JsProxy = document.createElement('ul')
ul: DOMElement = self.dom_adapter.create_element('ul')

for item in self.book.toc:
li: JsProxy = document.createElement('li')
a: JsProxy = document.createElement('a')
li: DOMElement = self.dom_adapter.create_element('li')
a: DOMElement = self.dom_adapter.create_element('a')
a.href = '#'
a.textContent = item['title']

# Define a handler function to be proxied
def create_handler(url: str) -> Callable[[JsProxy], None]:
def handler(event: JsProxy) -> None:
def create_handler(url: str) -> Callable[[DOMElement], None]:
def handler(event: DOMElement) -> None:
event.preventDefault()
self.display(url)
return handler

# Create a proxy for the onclick event handler
a.onclick = create_proxy(create_handler(item['url']))
a.onclick = self.dom_adapter.create_proxy(create_handler(item['url']))

li.appendChild(a)
ul.appendChild(li)
Expand Down
Empty file added tests/__init__.py
Empty file.
39 changes: 39 additions & 0 deletions tests/mocks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from typing import Any, Callable, Dict, List, Optional
from unittest.mock import Mock, MagicMock

class MockDOMElement:
"""A mock DOM element for testing."""
def __init__(self, tag_name: str) -> None:
self.tag_name: str = tag_name
self.children: List[MockDOMElement] = []
self.attributes: Dict[str, str] = {}
self.style = MagicMock()
self.innerHTML: str = ""
self.textContent: str = ""
self.href: str = "#"
self.onclick: Optional[Callable[[Any], None]] = None
self.onload: str = ""
self.src: str = ""
self.preventDefault: Mock = Mock()

def appendChild(self, child: "MockDOMElement") -> None:
self.children.append(child)

def setAttribute(self, name: str, value: str) -> None:
self.attributes[name] = value

class MockDOMAdapter:
"""A mock DOM adapter for testing."""
def __init__(self) -> None:
self.elements: Dict[str, MockDOMElement] = {}

def get_element_by_id(self, element_id: str) -> MockDOMElement:
if element_id not in self.elements:
self.elements[element_id] = MockDOMElement("div")
return self.elements[element_id]

def create_element(self, tag_name: str) -> MockDOMElement:
return MockDOMElement(tag_name)

def create_proxy(self, handler: Callable[..., Any]) -> Callable[..., Any]:
return handler
13 changes: 9 additions & 4 deletions tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,12 @@ async def test_main_application_flow(mock_js_object):
# Patch the `js` object and the `Rendition` class within the run_imposition module
with patch('run_imposition.js', mock_js_object), \
patch('run_imposition.Book', autospec=True) as mock_book_class, \
patch('run_imposition.PyodideDOMAdapter', autospec=True) as mock_dom_adapter_class, \
patch('run_imposition.Rendition', autospec=True) as mock_rendition_class:

# Get the mock instances that will be created by the script
mock_book_instance = mock_book_class.return_value
mock_dom_adapter_instance = mock_dom_adapter_class.return_value
# Give the mock book a spine so the script can access it
mock_book_instance.spine = ["chapter1.xhtml"]
mock_rendition_instance = mock_rendition_class.return_value
Expand All @@ -78,12 +80,15 @@ async def test_main_application_flow(mock_js_object):
epub_content = mock_js_object.pyfetch.return_value.bytes.return_value.to_py()
mock_book_class.assert_called_once_with(epub_content)

# 3. Verify that the Rendition object was instantiated with the book and viewer ID
mock_rendition_class.assert_called_once_with(mock_book_instance, "viewer")
# 3. Verify that the PyodideDOMAdapter was instantiated
mock_dom_adapter_class.assert_called_once_with()

# 4. Verify that the rendition instance was attached to the mock window
# 4. Verify that the Rendition object was instantiated with the book and viewer ID
mock_rendition_class.assert_called_once_with(mock_book_instance, mock_dom_adapter_instance, "viewer")

# 5. Verify that the rendition instance was attached to the mock window
assert mock_js_object.window.rendition is mock_rendition_instance

# 5. Verify that the TOC and the first chapter were displayed
# 6. Verify that the TOC and the first chapter were displayed
mock_rendition_instance.display_toc.assert_called_once_with()
mock_rendition_instance.display.assert_called_once_with("chapter1.xhtml")
Loading