DEV Community

Kavindu Kavishka Premarathna
Kavindu Kavishka Premarathna

Posted on

Building a Custom AI PDF Reader in Python: From a Jupyter Prototype to Tested Modules

Research papers are much easier to read when the reader fits the way you work. I wanted a PDF reader that could eventually support bookmarks, notes, annotations, summaries, question answering, text-to-speech, and voice commands.

Rather than trying to write a complete PDF engine from scratch, I started with a small Python prototype. The goal was simple: learn each layer of the application properly, build a working foundation, and only then move toward a desktop application.

This article documents the first stage of that journey: an interactive PDF reader in Jupyter, local persistence for bookmarks and notes, a cleaner module structure, automated tests, and the lessons learned along the way.

The project goal

The long-term goal is a customizable desktop PDF reader for research reading. The eventual application may include:

  • PDF navigation and zoom
  • Text search with highlighted matches
  • Bookmarks and reading progress
  • Page-linked notes and annotations
  • Local or cloud LLM summaries and question answering
  • Text-to-speech
  • Voice commands

For the first milestone, I deliberately kept the scope smaller. I focused on PDF rendering, navigation, search, persistence, and tests.

Choosing the first technology stack

I chose Python because it allowed me to experiment quickly. My first stack was:

Need Tool
PDF rendering, text extraction, and search PyMuPDF
Interactive prototype interface JupyterLab and ipywidgets
Image handling Pillow
Local saved data JSON
Version control Git and GitHub
Automated tests pytest

The future desktop UI will use PySide6, but Jupyter was a useful place to learn the reader logic before dealing with desktop-window layouts, signals, menus, and packaging.

Building the first reader

The prototype opens a local PDF with PyMuPDF:

from pathlib import Path
import pymupdf

PDF_PATH = Path("test.pdf")
document = pymupdf.open(PDF_PATH)

print(document.page_count)
Enter fullscreen mode Exit fullscreen mode

A PDF page is not automatically an image. PyMuPDF renders a page into a pixmap, and Pillow converts the pixel data into an image that Jupyter can display.

from PIL import Image

page = document[0]
pixmap = page.get_pixmap(dpi=120)

image = Image.frombytes(
    "RGB",
    (pixmap.width, pixmap.height),
    pixmap.samples
)

image
Enter fullscreen mode Exit fullscreen mode

This gave me the basic page view. From there, I added buttons for Previous, Next, Go to page, Zoom In, and Zoom Out.

A key lesson: application state

One important concept I learned was application state. Instead of letting each button manage unrelated variables, I kept the reader's current information together:

reader_state = {
    "current_page": 0,
    "zoom_dpi": 120,
    "search_results": [],
    "search_index": 0,
    "bookmarks": [],
    "notes": []
}
Enter fullscreen mode Exit fullscreen mode

The UI follows a simple pattern:

  1. A button changes reader_state.
  2. The application saves important changes if necessary.
  3. A refresh function renders the current state.

For example, page navigation uses one shared function:

def change_page(new_page):
    if not 0 <= new_page < document.page_count:
        return

    reader_state["current_page"] = new_page
    save_current_reader_data()
    refresh_reader()
Enter fullscreen mode Exit fullscreen mode

Using a single function for navigation prevents different controls from handling page changes in slightly different ways.

Searching and highlighting PDF text

PyMuPDF can find the rectangles where a text query appears on a page.

rectangles = page.search_for("research")
Enter fullscreen mode Exit fullscreen mode

The returned rectangles use PDF coordinates, measured in points. The rendered page image uses pixels. Since PDF points are based on 72 points per inch, I learned to scale each search rectangle with this formula:

scale = dpi / 72
pixel_x = pdf_x * scale
Enter fullscreen mode Exit fullscreen mode

That conversion lets the reader draw highlights in the correct position on the rendered image.

from PIL import ImageDraw

draw = ImageDraw.Draw(image, "RGBA")

for rect in rectangles:
    draw.rectangle(
        [
            rect.x0 * scale,
            rect.y0 * scale,
            rect.x1 * scale,
            rect.y1 * scale
        ],
        fill=(255, 235, 0, 90),
        outline=(255, 0, 0, 255),
        width=3
    )
Enter fullscreen mode Exit fullscreen mode

This was one of the most useful lessons in the project: PDF document coordinates and screen-image coordinates are not the same thing.

Adding persistent bookmarks and notes

Bookmarks and notes should survive a restart. I did not want to modify the original PDF for this first version, so I stored personal reader data in a JSON file.

A document entry looks like this:

{
  "documents": {
    "/absolute/path/to/test.pdf": {
      "file_name": "test.pdf",
      "bookmarks": [
        {
          "page_number": 2,
          "label": "Important result"
        }
      ],
      "notes": [
        {
          "page_number": 5,
          "text": "Review this figure before the presentation.",
          "created_at": "2026-08-11 15:30"
        }
      ],
      "last_page": 5
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

A bookmark points to a page and has a label. A note points to a page, has text, and stores when it was created. The current page is also saved whenever the user navigates.

I used a temporary file before replacing the main JSON file:

def save_all_reader_data(data):
    temporary_path = DATA_PATH.with_suffix(".tmp")

    with temporary_path.open("w", encoding="utf-8") as file:
        json.dump(data, file, indent=2, ensure_ascii=False)

    os.replace(temporary_path, DATA_PATH)
Enter fullscreen mode Exit fullscreen mode

This is safer than directly overwriting the main file because it reduces the risk of leaving a partially written JSON file if a save is interrupted.

Refactoring the notebook into modules

The first notebook worked, but it was becoming one large cell. That is acceptable for exploration, but difficult to maintain.

I moved reusable code into separate modules:

custom-ai-pdf-reader/
├── data/
│   └── reader_data.json
├── notebooks/
│   └── Untitled.ipynb
├── src/
│   ├── __init__.py
│   ├── pdf_service.py
│   ├── reader_state.py
│   └── storage_service.py
├── tests/
│   └── test_storage.py
├── .gitignore
├── pytest.ini
└── requirements.txt
Enter fullscreen mode Exit fullscreen mode

pdf_service.py

This module is responsible for PDF-specific tasks:

def open_pdf(pdf_path):
    ...

def get_page_count(document):
    ...

def render_page(document, page_number, dpi, highlight_rectangles=None):
    ...

def search_document(document, query):
    ...
Enter fullscreen mode Exit fullscreen mode

storage_service.py

This module handles JSON persistence:

def load_all_reader_data():
    ...

def save_all_reader_data(data):
    ...

def load_document_data(document_id, pdf_path):
    ...

def save_document_data(document_id, pdf_path, bookmarks, notes, last_page):
    ...
Enter fullscreen mode Exit fullscreen mode

reader_state.py

This module stores the reader's active state:

reader_state = {
    "current_page": 0,
    "zoom_dpi": 120,
    "search_results": [],
    "search_index": 0,
    "bookmarks": [],
    "notes": []
}
Enter fullscreen mode Exit fullscreen mode

The notebook now focuses on the interface and event handlers, while the reusable logic lives in Python files.

Writing automated tests

I added pytest tests for the storage layer. The tests use temporary folders, so they do not touch my real bookmarks, notes, or reader_data.json file.

Examples of what the tests verify:

  • Missing storage files return empty reader data.
  • Saved JSON can be loaded again.
  • A new document gets the expected default fields.
  • Bookmarks, notes, and last-read page are saved correctly.
  • The final JSON output is valid.

The first successful test run was a good milestone:

collected 5 items

5 passed in 0.02s
Enter fullscreen mode Exit fullscreen mode

This was also my first practical lesson in why automated tests matter. The interface can look correct while a save or load function still has a hidden problem. Tests give the project a repeatable safety net before making larger changes.

Problems I encountered

Building this project involved several useful mistakes and fixes.

Tkinter was unavailable

I initially considered a Tkinter desktop interface, but the Linux Python environment did not include the required Tk bindings. Instead of spending the first phase on GUI installation problems, I switched to Jupyter widgets for the prototype and chose PySide6 for the future desktop application.

Ubuntu had an APT lock

An Ubuntu background update held the package-manager lock. The correct response was to wait and inspect the running update, not to delete lock files or force-stop the process.

Widgets displayed incorrectly

At one stage, interactive Jupyter widgets appeared as plain text or did not respond to clicks. The problem was environment setup: Jupyter, the Python kernel, and ipywidgets need to be connected to the same project environment. Restarting the kernel and testing a minimal button helped isolate the issue.

Old notebook functions conflicted with new modules

When I moved code into modules, I accidentally kept old versions of functions such as render_page(), search_document(), and JSON storage functions inside the notebook. This created duplicate names and confusing behavior. The fix was to keep the reusable function in one module and import it into the notebook.

Git repository inside another Git repository

I created a new project folder inside an existing Git repository and accidentally ran git init inside the nested folder. That created a second .git directory. The correct approach was to remove only the accidental nested .git folder and use the original repository at the parent level.

Pytest could not import src

My tests initially failed with ModuleNotFoundError: No module named 'src'. I fixed that by adding src/__init__.py and configuring pytest with a pytest.ini file.

[pytest]
pythonpath = .
testpaths = tests
Enter fullscreen mode Exit fullscreen mode

The tests then passed.

GitHub workflow

I learned to use a safer Git workflow for each tested milestone:

git status
git add custom-ai-pdf-reader/src/
git add custom-ai-pdf-reader/tests/
git add custom-ai-pdf-reader/notebooks/
git commit -m "Add modular PDF reader prototype and storage tests"
git push
Enter fullscreen mode Exit fullscreen mode

The .gitignore file is important because local PDFs, the virtual environment, temporary cache files, and personal reader data should not be uploaded.

__pycache__/
*.py[cod]
.pytest_cache/
.venv/
.ipynb_checkpoints/
data/
*.pdf
Enter fullscreen mode Exit fullscreen mode

What I learned

This project taught me more than how to display a PDF in Python. The main lessons were:

  • A PDF is a document description, not simply an image.
  • Rendering DPI affects both clarity and memory usage.
  • PDF points must be scaled when drawing highlights on a pixel image.
  • Application state makes UI behavior easier to understand and maintain.
  • Event-driven interfaces use callbacks that run when users click buttons.
  • JSON serialization makes application data survive a restart.
  • Temporary-file replacement is safer than directly overwriting data files.
  • Zero-based page indexes in Python must be translated into human page numbers.
  • Reusable modules make code easier to test and later reuse in a desktop application.
  • Tests catch storage problems before they affect real user data.
  • Git repositories should have one clear root; accidentally nesting repositories causes confusion.

Next steps

The Jupyter prototype now has a tested storage layer. The next milestone is a PySide6 desktop MVP with:

  • A real desktop window
  • An Open PDF action
  • A scrollable rendered PDF page
  • Previous and Next controls
  • Page-number navigation
  • Zoom controls
  • A status bar

After the desktop foundation is stable, I plan to migrate persistence from JSON to SQLite, add text selection and annotations, then explore AI summaries, PDF question answering with citations, text-to-speech, and voice commands.

Closing thoughts

Starting small was the right choice. I did not begin by trying to build an AI system or a complete commercial PDF application. I started with one page, then navigation, then search, then bookmarks, notes, saved progress, modules, tests, and version control.

Each feature made the next one easier to understand. The project is still at an early stage, but it already has a working and tested foundation for a more capable custom research reader.

If you are building your own developer project, my advice is simple: keep the first version small, make each milestone testable, and commit working progress often.

Top comments (0)