feat: pdf reader

This commit is contained in:
2026-06-03 22:46:38 -05:00
parent d9c66e68a6
commit f091386974
29 changed files with 29469 additions and 2593 deletions
+3 -1
View File
@@ -91,13 +91,15 @@ class EBookListSerializer(serializers.ModelSerializer):
return None
def get_started(self, obj):
"""True when the user has opened the reader and has a saved EPUB location."""
"""True when the user has opened the reader with saved progress."""
try:
rp = obj.reading_progress
except ReadingProgress.DoesNotExist:
return False
if rp.current_position >= 99:
return False
if obj.format == "pdf":
return rp.last_page > 0 or rp.current_position > 0
return bool((rp.epub_location or "").strip())
+63 -1
View File
@@ -89,7 +89,69 @@ def _process_epub(file_path: str) -> dict[str, Any]:
def _process_pdf(file_path: str) -> dict[str, Any]:
return {"format": "pdf", "page_count": 0, "metadata": {}, "toc": []}
from pypdf import PdfReader
reader = PdfReader(file_path)
page_count = len(reader.pages)
metadata = _extract_pdf_metadata(reader)
toc = _extract_pdf_outline(reader)
if not toc and page_count > 0:
toc = [
{"title": f"Page {i}", "href": f"pdf:page:{i}", "children": []}
for i in range(1, page_count + 1)
]
return {
"format": "pdf",
"page_count": page_count,
"metadata": metadata,
"toc": toc,
}
def _extract_pdf_metadata(reader) -> dict[str, Any]:
metadata: dict[str, Any] = {}
info = reader.metadata
if not info:
return metadata
title = getattr(info, "title", None)
author = getattr(info, "author", None)
if title:
metadata["title"] = str(title)
if author:
metadata["author"] = str(author)
return metadata
def _extract_pdf_outline(reader) -> list[dict[str, Any]]:
outline = getattr(reader, "outline", None)
if not outline:
return []
return _walk_pdf_outline(reader, outline)
def _walk_pdf_outline(reader, outline: list[Any]) -> list[dict[str, Any]]:
entries: list[dict[str, Any]] = []
i = 0
while i < len(outline):
item = outline[i]
if isinstance(item, list):
if entries:
entries[-1]["children"] = _walk_pdf_outline(reader, item)
i += 1
continue
title = getattr(item, "title", None) or "Section"
page_num = 1
try:
page_num = reader.get_destination_page_number(item) + 1
except Exception:
logger.debug("Could not resolve PDF outline destination", exc_info=True)
entries.append({
"title": str(title),
"href": f"pdf:page:{page_num}",
"children": [],
})
i += 1
return entries
def _extract_epub_metadata(book) -> dict[str, Any]:
+9 -4
View File
@@ -168,18 +168,23 @@ class EBookViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=["get"])
def file(self, request: Request, pk: int | None = None) -> FileResponse | Response:
"""Stream the EPUB file for authenticated in-browser reading."""
"""Stream the ebook file for authenticated in-browser reading."""
ebook = self.get_object()
if ebook.format != "epub":
content_types = {
"epub": "application/epub+zip",
"pdf": "application/pdf",
}
content_type = content_types.get(ebook.format)
if not content_type:
return Response(
{"error": "Reader supports EPUB only."},
{"error": "Unsupported format for in-browser reading."},
status=status.HTTP_400_BAD_REQUEST,
)
if not ebook.file:
return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST)
return FileResponse(
ebook.file.open("rb"),
content_type="application/epub+zip",
content_type=content_type,
filename=ebook.filename(),
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+1
View File
@@ -21,4 +21,5 @@ dependencies = [
"httpx>=0.28.0",
"ebooklib>=0.18",
"beautifulsoup4>=4.12.0",
"pypdf>=5.0.0",
]
+11
View File
@@ -52,6 +52,7 @@ dependencies = [
{ name = "psycopg2-binary" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pypdf" },
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "pytest-django" },
@@ -74,6 +75,7 @@ requires-dist = [
{ name = "psycopg2-binary", specifier = "==2.9.10" },
{ name = "pydantic", specifier = "==2.10.5" },
{ name = "pydantic-settings", specifier = "==2.7.1" },
{ name = "pypdf", specifier = ">=5.0.0" },
{ name = "pytest", specifier = "==8.3.4" },
{ name = "pytest-cov", specifier = "==6.0.0" },
{ name = "pytest-django", specifier = "==4.9.0" },
@@ -567,6 +569,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
]
[[package]]
name = "pypdf"
version = "6.12.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0a/6d/20879428577c1e57ecd41b69dc86beabf43db9287ad2e702207f8b48c751/pypdf-6.12.2.tar.gz", hash = "sha256:111669eb6680c04495ae0c113a1476e3bf93a95761d23c7406b591c80a6490b1", size = 6468184, upload-time = "2026-05-26T13:31:26.911Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/44/fee070a16639d9869bb6a7e0f3a1b3946da1d66f32b9260b4d19cb90d7b2/pypdf-6.12.2-py3-none-any.whl", hash = "sha256:67b2699357a1f3f4c945940ea80826349ee507c9e2577724a14b4941982c104d", size = 343865, upload-time = "2026-05-26T13:31:25.068Z" },
]
[[package]]
name = "pytest"
version = "8.3.4"