smartreader
Read a PDF, EPUB, Word document or plain text file, highlight the part that lost you, and have it explained in plain English — by a model running on your own machine. Nothing you read, highlight or write leaves the computer, and the app works with the network off.
A Wails desktop app: Go backend, Svelte 5 frontend, and a local Qwen3.5 9B served by the model runtime every kumulativ app shares (kumulativ-core). It shares its stack and its design language with smartwriter and smartdiary.
What it does
Import. Point it at a file, or drop one — or a folder’s worth — anywhere on the window. It extracts the text once, works out the chapter structure, and stores the result — the original file is never modified and never needs to be present again.
Read. One column of serif text on paper, with a chapter list on the left.
The reading controls live on an Aa button floating at the bottom-right corner
of the page — not in the toolbar, because the moment you want them is the moment
you are looking at the text and finding it too small. It opens a panel of
sliders over the page: type size, line height, paragraph spacing, column width
(or fill the window), serif/sans and justification, each applying as you drag.
Ctrl/⌘ with +, − and 0 change the size from the keyboard. The reading
position is remembered per book.
This is a reflowing reader, like an e-reader or a browser’s reader mode, not a page viewer: the text is set in your type at your width, which is what makes highlights, resizing and the model’s context all work on the same string. What it keeps from the original is its structure — chapter and section headings, italic and bold emphasis, footnotes, tables and pictures — and what it gives up is its layout: page images, columns and equations do not survive. A scanned page or a formula-heavy paper is better read elsewhere.
Tables come through as tables from EPUB and Word files, where the format states them. A PDF has no tables, only glyphs, but a table leaves a shape behind — lines in one size whose pieces start at the same few positions, with gutters wider than any word space — and the importer reads that shape back into rows and cells, in the order the file drew them, joining a table that runs over a page break back into one. A table set without gutters, or drawn as lines rather than set as text, is not found. Pictures come through from all three formats. In an EPUB or a Word file each one sits where the author put it, with its caption. In a PDF only the page a picture was on is known, so it is set down after the paragraph that page was in the middle of, and a figure drawn with lines rather than embedded as an image is not a picture at all and is lost. Footnotes are kept out of the reading column and shown on the panel’s Footnotes tab, each with a way back to the sentence that cites it.
Because everything is set from the extracted text, the extraction has to be
faithful, and a PDF makes that hard: it holds glyphs and positions, not
paragraphs. The PDF library is carried in the tree (third_party/pdf, a copy of
ledongthuc/pdf) with one fix: glyph widths for composite (Identity-H) fonts,
which upstream reads as zero, so that every position on a page set by TeX or
exported from a modern word processor is where the page put it. The importer reads the glyphs in the order the file draws them,
so a footnote number stays on its word (as a superscript) instead of being
sorted to the front of the line, and a two-column page reads column by
column. Paragraphs come from the page geometry — a first-line indent, or
extra space above — rather than from guessing at sentence endings, and a
paragraph that runs over the bottom of a page carries on at the top of the
next. Footnotes, which the page sets in the middle of whatever paragraph ran
over it, are moved to after that paragraph, one block per note. Running
heads and page numbers are dropped. A word hyphenated across a line break is
rejoined, unless the book itself shows it to be a compound the author
hyphenated. Word documents keep their footnotes, their numbering and the
emphasis carried by character styles; EPUBs pick up emphasis that lives in
the stylesheet rather than in <em> tags.
Highlight → explain. Select any words. A small bar appears with four things worth doing in the second after letting go of the mouse:
| Action | What it does |
|---|---|
| Explain | What the passage means, in plain English |
| Simpler | The same thing, as if to someone new to the subject |
| Define | The term as it is used here, not in general |
| ●●●●● | Keep the highlight, no model involved |
| Note | Keep it and write your own thought about it |
The answer streams into the study panel on the right, which also carries Why it matters and a free-form Ask about this passage box. Drag its left edge to resize it — a definition wants a narrow panel and the whole page, a long explanation beside the passage it is about wants a wide one. Double-click the edge to put it back. The width is remembered like the type size, and the panel never takes more than half the window, so the column it sits beside cannot be squeezed away.
Double-click a word and a small card opens over it with what the word means there — the plain meaning first, then what it is doing in that sentence. The definition is kept with the book: double-click the same word on page ninety and the card fills in at once from disk, without the model. A double-click is the lightest question the app answers and the one asked most often, so it is the one that never costs more than it has to.
Every explanation is kept. Asking about a passage is itself the act of marking it — it is the sentence that stopped you — so the answer is saved as soon as it lands and the passage carries a highlight from then on, with a firmer underline and a dot to say it has an explanation behind it. Click the mark, or select those words again, and the answer you already have comes back rather than a second one being generated. Nothing is kept when a request fails: a highlight over a passage with no explanation behind it would misreport what happened.
Notes. Every highlight, explanation and note across every document, grouped by book, searchable, with Open in context to jump back to the exact paragraph. Export a book’s notes as Markdown.
Study this. A button under each book on the shelf, and in the top bar while reading one. Press it once and the model reads the whole book — chapter by chapter, in stretches that fit its window — and writes a summary of each chapter and of the book. Those summaries are never shown as an answer to anything; they are background. From then on, every explanation of a passage in that book is given the book’s summary and the summary of the chapter the passage is in, ahead of the surrounding paragraphs, so a name introduced forty pages earlier or a pronoun pointing at the previous section can be resolved rather than guessed at. A book that has not been studied is explained exactly as before.
The pass takes a few seconds per chapter on a GPU and a good deal longer on a CPU, and it runs in the background: keep reading, open another book, write notes. Its progress — which chapter, how far — shows on the card, in the reader’s top bar, and in the nav bar from any screen, and it can be stopped from any of them; a stopped pass keeps nothing. Highlighting while it runs does not wait on it. The model serves one request at a time, so the summarising call in flight is cut short the moment a passage is asked about, the answer streams at once, and the study picks the same stretch up again when the answer is in. The bar says Paused for your question while that happens.
Where things live
<UserConfigDir>/smartreader/
data.json the library index, the notes, the settings
docs/<id>.json one document's extracted text, written once at import
docs/<id>.study.json its book and chapter summaries, written by "Study this"
docs/<id>.words.json the words looked up in it by double-click, and their definitions
<UserConfigDir>/kumulativ/ shared with every kumulativ app — see kumulativ-core
models/ the Qwen weights, one copy for every app
engines/ the llama.cpp build for this machine
run/ the one running server: registry, lock, presets
The split between data.json and docs/ is deliberate: the index is rewritten
on every mutation and a book’s text is megabytes, so saving a note never
rewrites a novel. Both are written through a temp file and a rename, so a crash
cannot leave a half-written library behind.
Environment variables
| Variable | Effect |
|---|---|
SMARTREADER_CONFIG_DIR |
Move the whole data directory (portable install, tests) |
SMARTREADER_GGUF |
Use this .gguf and download nothing — any architecture the engine supports |
KUMULATIV_HOME |
Move the shared model store |
KUMULATIV_BIN_DIR |
Look for llama-server here first |
KUMULATIV_IDLE_UNLOAD_MIN |
Minutes of quiet before the model is put to sleep (default 1; 0 = never) |
KUMULATIV_LLAMA_ASSET / KUMULATIV_LLAMA_RELEASE |
Pin a specific llama.cpp release asset |
KUMULATIV_LLAMA_EXTRA_ARGS |
Extra flags for the llama-server command line |
KUMULATIV_FORCE_CPU |
Never use the CUDA build |
KUMULATIV_NO_ENGINE_FETCH |
Refuse to download an engine |
KUMULATIV_DEBUG |
Forward the server’s output instead of swallowing it |
How it is put together
Everything is a block
Each format lands in the same shape: chapters of blocks, where a block is one
paragraph-sized unit of text with an id assigned at import (c3-b17) that never
changes. The reader, the highlight anchors and the context the model is given are
all written against blocks and know nothing about PDFs or EPUBs.
A highlight is stored as (startBlock, startOffset) → (endBlock, endOffset) —
character offsets into the block’s text. That survives restarts, window resizing
and font changes, none of which a pixel or scroll-based anchor does.
This rests on one invariant, which BlockView.svelte exists to protect: the text
nodes inside a block element, concatenated in document order, are exactly that
block’s text. The segment loop in that component is written on one line with no
whitespace between nodes, because a newline in the template would become a real
text node and slide every offset after it.
What each format gives us
| Format | Structure comes from |
|---|---|
| EPUB | The spine (chapter order) and the nav document or NCX (titles) |
| DOCX | Heading 1 starts a chapter; the paragraph styles give block kinds |
| Markdown | # starts a chapter, ##/### are headings inside one |
| Inferred — see below |
A PDF has no chapters, only pages and glyph positions — and often not even words. Text is rebuilt from the geometry of individual characters: lines from baselines, words from horizontal gaps. Structure is rebuilt from the typography: the body type size is the size most of the book’s characters are set in, lines noticeably larger than it are headings (tiered by how much larger), lines noticeably smaller are footnotes, and the font name of each glyph says whether it is italic or bold. Running heads are dropped by finding lines that repeat at the edges of pages, hard-wrapped lines are rejoined into paragraphs, and a “CHAPTER 3” label set above its title in a smaller size is folded into the title. When a book carries no headings at all, it is cut into even sections, because a single chapter containing a whole book makes both the reader and its chapter list useless.
Emphasis rides through the repair pipeline as private-use characters and is
stripped into Block.Spans at the very end (markers.go). That ordering is the
point: every string operation runs on text that still carries the markers, so
the offsets recorded are offsets into exactly the text the reader sees — in
UTF-16 units, because JavaScript will be the one slicing.
Word reconstruction has to handle two opposite kinds of file, and getting either one wrong is very visible:
- Producers that emit real space glyphs. Their spaces are trusted as-is.
Inferring on top of them puts one between every letter (
W h o i s t h i s). - TeX and friends, which emit no spaces at all. Word gaps are numeric
offsets inside the
TJarray, so the text arrives asWhatoneissavedfromunless those offsets are read back out.
The page decides which it is by counting its own space glyphs, because a producer is consistent across a page whereas a single line may legitimately be one long word.
This is also why extraction goes through Page.Content() rather than the
library’s GetPlainText/GetTextByRow helpers. Both helpers walk a TJ array
taking only its strings and discarding the numbers between them — which is
precisely where the spaces are — and GetTextByRow additionally groups by a Y
coordinate it only updates on some operators, so a TeX file reports Y=0 for
every glyph on the page and the whole page collapses into one line.
Two PDFs are refused rather than imported badly:
- A scan. No text layer at all — the error says so and names OCR.
- A file with no usable character map. Subset fonts with no
ToUnicodetable decode to long runs of punctuation. That imports without error and looks like a real book right up until it is opened, soreadableTextcatches it: real prose is mostly letters and has a space about every sixth character.
The model
prompts.go builds every message the model sees, and the quality of a 4B model’s
answer is mostly a property of that file. The system prompt is short and
concrete; each lens is a single imperative rather than a list of things the model
might also cover. Context is assembled in the backend from the two blocks either
side of the highlighted one — the frontend sends the anchor, not the text, so the
selection handler never has to decide how much of the page to send.
study.go is the “Study this” pass, a plain map-reduce over the same blocks:
each chapter is cut into stretches of about ten thousand characters on
paragraph boundaries, each stretch is summarised, a chapter that needed several
stretches has them folded into one, and the book summary is written from the
chapter summaries — folded the same way if there are more than fit one call.
Chapters too short to be worth a call are kept verbatim as their own précis.
The result is a DocStudy beside the text, and Explain reads it on every
question: the book summary and the passage’s chapter summary go into the prompt
first, clamped to a small budget each, because they only place the passage and
the paragraphs around it are what the question is about. The pass runs under
the same cancellation map as explanations, so StopGeneration("study:<id>")
stops it; a stopped pass writes nothing and is not an error.
Frontend
api.ts is the only file that touches the Wails bridge. Outside the desktop app
it falls back to a mock backend with a sample book and canned, word-by-word
streamed answers, so the whole UI — including the selection, highlight and
streaming machinery — is workable in a plain browser.
Known limits
- Scanned PDFs need OCR first. There is no OCR in the app.
- PDFs with broken font maps are refused. A subset font with no
ToUnicodetable decodes to glyph soup (9:·Fn¢9·¢²¢), and importing that produces a book that looks fine in the library and is unreadable when opened. Re-saving from a viewer usually fixes the file; the error message says so. Note that a file can be partly broken — a readable title page over an unreadable body — and is still refused, because the body is the book. - Multi-column PDFs are not detected. Text is taken in the order the content stream emits it, which is usually column by column and therefore right, but a producer that interleaves columns would interleave them here too.
- PDF paragraphs are inferred, not read. Some PDFs give up no line breaks at all, in which case oversized paragraphs are cut at sentence boundaries into readable pieces. They are not the author’s paragraph breaks and the app does not pretend otherwise.
- Re-importing a file creates a second library entry, with its own notes. The original file’s path is recorded but not watched.