Read a scientific paper with an LLM beside it: the PDF on the left, the chat on the right, and a panel where the model writes runnable Python to animate the paper's mathematics.
Highlight a passage or frame a figure, and ask for an explanation. Every answer cites its pages; clicking a citation jumps to the exact spot and highlights it.
Everything runs locally against LM Studio. Nothing leaves your machine unless you deliberately configure a cloud provider.
Double-click install.bat, once. It looks for Python 3.11 or newer —
installing it through winget if it finds none — creates an isolated
environment in .venv, installs the dependencies, checks that the application
actually loads, and puts a shortcut on your Desktop. Allow a few minutes and
roughly 300 MB the first time.
Everything stays inside the project folder: uninstalling means deleting it.
Two things remain, deliberately outside the installer's scope:
- LM Studio — https://lmstudio.ai
- A model that reads text and images, for example
google/gemma-4-12b-qat, loaded and then served from the Developer tab (Start Server).
After that, day to day, double-click ExplainMyPDF.bat or the Desktop
shortcut: it starts the server, opens the browser at the right address, and
warns you if LM Studio is not running.
Another port if you need one: ExplainMyPDF.bat 8123.
Manual installation, if you prefer
conda create -n explainmypdf python=3.11 -yconda activate explainmypdf; pip install -r requirements.txtExplainMyPDF.bat accepts either: it uses the project's .venv when one
exists, and falls back to the explainmypdf conda environment otherwise.
The command-line equivalent is .\run.ps1, then open http://127.0.0.1:8000.
During development, .\run.ps1 -Reload restarts the server on every change to
the Python code.
The safest way is the red cross at the top left. It asks for confirmation, shuts the server down cleanly, and shows a screen saying so.
Closing the .bat window works too, but not every time: the server can outlive
it. And since a relaunched .bat then finds a healthy server and simply reopens
the tab, you believe you have restarted while still running the old code — which
makes a backend change perfectly invisible. As a last resort:
Get-NetTCPConnection -LocalPort 8000 -State Listen | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }The dropdown in the top bar switches the interface and the language of the answers — French or English. The choice is remembered; on first launch it follows the browser's.
There are two languages in this application, and conflating them is the trap:
- The reader's, which that dropdown drives. It travels with every question
and decides the wording of every instruction, and therefore the language of
the answer. All of its text lives in two files:
backend/app/context/phrasing.pyfor what the model is told,frontend/js/i18n.jsfor what the reader sees. Adding a language means one entry in each. - The paper's, which follows no setting at all. The extraction heuristics — caption patterns, "where …" clauses, defining verbs, bibliography detection — assume English, because scientific papers are in English. A German reader of an English paper is served correctly by English-tuned extraction; making it follow the dropdown would break the common case to serve one that does not exist.
One practical consequence: the French → English hint table in retrieval.py
exists only because the reader and the paper disagree. An English reader of an
English paper shares its vocabulary and needs none of it. A third language would
have no table and would lean on the embeddings, which are multilingual by
construction — and the status panel warns you when no embedding model is loaded,
because lexical-only search across two languages does not work.
The application talks to LM Studio by default, on http://localhost:1234.
There is no model name to configure: it asks the server what is loaded and uses
that. Switching models in LM Studio is enough — no file to edit, no restart.
For another provider, copy .env.example to .env:
EMP_PROVIDER |
What it needs |
|---|---|
lmstudio |
LM Studio, server started from the Developer tab (default) |
openai |
EMP_OPENAI_API_KEY |
anthropic |
EMP_ANTHROPIC_API_KEY |
The selector at the top right switches without restarting. Unreachable backends stay visible, greyed out, with the reason — an empty menu would look like a bug in the application rather than a server that was never started.
- Download a model in LM Studio.
- Developer tab → Start Server (port 1234 by default). Without this step the application sees nothing: LM Studio only exposes its API once the server runs.
- Load the model, or leave it to load on demand.
- In ExplainMyPDF, pick
lmstudioin the model selector.
To check the state at any point:
powershell -ExecutionPolicy Bypass -File .\scripts\watch-lmstudio.ps1And to measure what the model is actually worth on your paper:
.\.venv\Scripts\python.exe scripts\try_model.py --provider lmstudioThat bench replays a series of questions and reports, for each: time to first token, throughput, the size of the context sent, and above all how many citations actually anchor in the PDF. That is the measure that matters — a citation that does not anchor is an invented one.
Keeping a cloud provider configured has a concrete use: when a local answer is poor, replaying the same question on a large model tells you whether the fault lies with the model or with the context assembled for it.
This is the setting that changes speed, by a wide margin:
lms load google/gemma-4-12b-qat --context-length 8192 --gpu max --parallel 1Measured on one machine, same question, same code — only --parallel changes:
--parallel |
Time to first token |
|---|---|
| 4 (default) | 22 s, 22 s, 32 s |
| 1 | 6.1 s, 5.6 s, 6.6 s |
The reason: llama.cpp splits the context window across slots. With 8192 tokens and 4 slots, each gets only 2048 — less than ExplainMyPDF's prompt, which must then be recomputed in full for every question. One slot is enough for one reader.
(The prompt structure was reorganised — stable prefix first, variable part after — on the theory that it would help. The measurement is unambiguous: the gain comes from the slot count, not from that.)
Gemma 4 thinks before answering, and its working arrives on a separate channel
(reasoning_content). ExplainMyPDF shows it in a collapsible block instead of
mixing it into the answer — citations and code are extracted from the answer
alone, never from the draft.
Thinking is off by default, via the Thinking button in the top bar.
Measured on gemma-4-12b-qat, RTX 4070 Laptop 8 GB:
| Question | With thinking | Without |
|---|---|---|
| Explain the DTW equation | 220 s, 7/7 citations anchored | 75 s, 5/5 |
| Best AUC using PLI | 153 s, 1/1 | 27 s, 3/3 |
Quality does not drop; the length of the reasoning, however, varies wildly — between 1700 and 3500 tokens across runs of the same question, enough to exhaust the budget and cut the answer off mid-code. When that happens the application says so explicitly, rather than letting truncated code pass for broken code.
Turn thinking on for a genuinely hard question, not by default.
On 8 GB of VRAM the comfortable range is 7–12 B quantised to 4 bits. Figure analysis needs a multimodal model; without one, framed regions are useless. A single multimodal model covers both jobs.
- Viewer — server-side rendering with PyMuPDF, and a selectable text layer rebuilt from word boxes.
- Layout-aware extraction — columns found by their gutters, correct reading order, end-of-line hyphenation rejoined, printed page numbers recovered (the sample paper starts at page 741, behind a repository cover sheet).
- Clickable objects — figures, tables and equations detected and matched to their captions. One click; no need to frame anything by hand.
- Selection — highlight text, or hold Alt and drag to frame a region that travels to the model as an image.
- Anchored citations —
[p.744 "exact words"]becomes a clickable chip. An invented citation fails to anchor, and that shows. - Code panel — Python from the answer lands in an editor, runs, and renders figures and animations. If it crashes, a button sends the traceback back to the model to fix. Animations are capped at 150 frames: a model asked to animate a matrix will happily write one frame per cell, which never finishes.
- Explanation level — general public / student / researcher.
- Outline — extracted table of contents and abstract, clickable.
- Notation — a glossary of the symbols and acronyms the paper defines, each linked to where it defines them. See below.
- Inspectable context — under every answer, the exact prompt the model received. This is what makes a bad answer diagnosable.
- Persistent session — the conversation survives a reload and exports to Markdown.
- Status — a discreet dot in the top bar opens a panel saying which model will answer, whether it reads images, the loaded context size, the search mode and disk usage. This is where silent failures become visible.
Two sources, told apart on screen:
- Extracted from the text (default) — acronyms introduced properly, as in
"Phase-Lag Index (PLI)", and symbols defined in the where clause that
follows an equation:
|.|,∆Φ(tk),i,j. These are quotations. - Completed by the model (Complete with the model, optional) — the acronyms the paper uses without ever spelling them out, the ones a reader cannot look up. Shown in italics, because they are inferences, not quotations.
The distinction is not cosmetic: an extracted definition can be checked against the PDF, an inferred one needs your judgement.
backend/app/
pdf/layout.py columns, lines, equations, tables, watermark detection
pdf/document.py parsed document, page rendering, crops
llm/ providers: LM Studio / OpenAI, Anthropic
context/ BM25 retrieval, prompt assembly, citations, phrasing
api/ documents, chat (SSE), code execution
frontend/ HTML/CSS/JS, no build step
frontend/vendor/ KaTeX — the only vendored dependency
frontend/tests/ browser bench for the Markdown renderer
tests/ regression tests on extraction and citations
One coordinate system runs through the whole stack: PDF points, exactly as PyMuPDF reports them. The frontend converts only at display time, multiplying by the zoom. A rectangle can therefore travel from a mouse drag to a crop request to a citation highlight without ever being reinterpreted.
Models write formulas in LaTeX. They are typeset by KaTeX, vendored in
frontend/vendor/katex/: the published release (Apache 2.0), trimmed to the
woff2 fonts, 550 KB. Nothing to install and no CDN — the app stays usable
offline. To update it, replace katex.min.js, katex.min.css and
fonts/*.woff2 with those from a newer release.
Formulas are lifted out of the text before HTML escaping, exactly like
citations and for the same reason: LaTeX is full of <, > and & that KaTeX
must receive verbatim. That also puts them out of reach of the Markdown pass,
which would otherwise read the stars in a * b * c as italics.
The $…$ delimiter needs care, because French writes amounts as "0.006 $" with
the sign after the number. Three guards separate the two cases: no whitespace
just inside either delimiter, no newline in the formula, and no digit right
after the closing $. Those cases are locked down in the browser bench.
The script runs in a separate process, in an empty directory, with a timeout, and its output is captured. That is enough to contain an infinite loop or a careless file write.
It is not a security boundary. The code runs with your privileges. The editor above the Run button is not decorative: read what the model wrote before you run it.
Two methods run in parallel and their rankings are fused:
- BM25 (lexical) — unbeatable on the acronyms and proper nouns a methods section is made of: "PLI", "Sakoe-Chiba", "OFR".
- Embeddings (semantic) — indispensable across languages: you write in French, the paper is in English. "how are the probability scores obtained?" finds nothing lexically.
Embeddings switch on by themselves if LM Studio exposes an embedding model
(nomic-embed-text ships with LM Studio by default). Otherwise search stays
lexical and says so under every answer. The index builds in the background on
the first question and is cached per document, so it is free afterwards.
nomic-embed-text-v1.5 is mostly anglophone: it crosses into French but by thin
margins. A multilingual model (bge-m3, multilingual-e5-large,
nomic-embed-text-v2-moe) does better, and is picked up automatically if you
load one instead.
Three things are kept out of retrieval, because they match everything and answer nothing: the repository cover page, the front matter (authors, affiliations, keywords) and the bibliography.
scripts/check_layouts.py runs extraction over a batch of PDFs and reports
health signals (columns, reading order, outline, objects found):
.\.venv\Scripts\python.exe scripts\check_layouts.py paper1.pdf paper2.pdfTested on five documents with different layouts: two columns with a cover sheet and offset pagination (BIOSIGNALS), two columns with clean numbering (an NGO report), single column (Scientific Reports), a 27-page MDPI review, and a 167-page thesis manuscript — the last analysed in 16 s, with 69 figures, 109 equations and 32 tables detected.
- MDPI review: no objects detected — its figures and tables are drawn without rules or images — and paragraphs stay long. Not crippling in practice: framing a region by hand sends the image and the exact words underneath it, so the numbers remain reliable and quotable. What detection adds on top is the clickable target and the table's presence in retrieval; without it, you have to know where to look.
- Table 6 of the sample paper is not detected: its rules do not form the expected pattern. The other six are.
- Seven tables with near-identical vocabulary cannot be told apart by retrieval alone. Naming the object ("in Table 2…") triggers a direct lookup, and a retrieved passage that refers to a table brings it along.
- Scanned PDFs — no OCR. A PDF with no text layer yields nothing.
- The layout heuristics are calibrated on a two-column paper. They degrade gracefully on a single column, but an exotic format will need adjustment.
.\.venv\Scripts\python.exe -m pytest tests -qThe repository includes 131697.pdf, the paper every heuristic was
calibrated on, because without it the suite can verify almost nothing:
conftest.py looks for it at the root and cleanly skips the tests that depend
on it when absent — 21 of 74 survive. With it, the repository verifies itself
from the moment it is cloned.
Most tests encode a bug that actually happened. Extraction heuristics fail silently — a deleted equation or two interleaved columns still produce plausible text, whose effect only surfaces three layers later as a strange answer. Hence the locking down.
The Markdown renderer has its own bench, in the browser since there is no Node:
open /static/tests/markdown.html while the server is running. Each case is
rendered with the real renderer in the real CSS, and then the resulting DOM is
inspected — counting elements rather than comparing strings is deliberate: every
display bug in this project survived assertions on HTML and only appeared
against a real layout.
GNU AGPL-3.0 — full text in LICENSE.
This is not an arbitrary choice. PDF extraction rests entirely on PyMuPDF, distributed under a dual AGPL-3.0 or Artifex commercial licence. Any redistribution of ExplainMyPDF — a binary as much as a copy of the folder — therefore places the whole under the AGPL, and requires offering the corresponding source to recipients. Publishing the repository satisfies that obligation by construction.
Using the software for yourself carries no obligation: they attach to distribution only.
The other dependencies are permissive (BSD, MIT, PSF), as is KaTeX
(Apache-2.0), vendored in frontend/vendor/katex/.