Building Theorem: A Local-First Reading App

How I Built a Local-First Reading App I love reading books and annotating them. What I love more is being able to own those books and annotations digitally. What I don’t love is having my library, highlights, and reading progress …

Published
Reading Time
7 min read
Author
Sabin Pokharel

How I Built a Local-First Reading App

How I Built a Local-First Reading App image
How I Built a Local-First Reading App image

I love reading books and annotating them. What I love more is being able to own those books and annotations digitally. What I don't love is having my library, highlights, and reading progress locked inside someone else's cloud. Every e-book reader I tried either required an account, synced through a proprietary server, or couldn't handle the formats I actually use EPUB, MOBI, PDF, CBZ/CBR comics, and even RSS feeds. When I discovered Readest, an excellent open-source e-book reader, I was impressed but disappointed with cluttered UI and complex settings.

So I built Theorem: a local-first reading app that does everything offline and syncs peer-to-peer, with no server, no accounts, and no data leaving your network. It reads EPUB, MOBI, AZW, AZW3, FB2, CBZ, CBR, and PDF.


Architecture: Tauri 2 + Rust + React 19

Theorem is built on Tauri 2, which gives a native Rust backend with a WebView frontend. The stack is:

  • Backend: Rust (Tauri 2 commands, file system access, HTTP server, ONNX inference)
  • Frontend: React 19 + TypeScript + Tailwind CSS v4, rendered via WebView
  • State management: Zustand 5 with persist middleware and versioned migrations
  • Persistence: SQLite via rusqlite (desktop) / IndexedDB (web fallback)
  • P2P Sync: X25519 + ChaCha20-Poly1305 encryption over HTTP, QR-code pairing
  • TTS: Kokoro-82M ONNX models running locally via kokoro-en + ort crates

The key decision was moving all data-heavy processing to Rust — file parsing, text extraction, encryption, and TTS inference while the React frontend handles layout, rendering, and interactivity.

Multi-Format Ebook Reader

Rather than reinventing the wheel, I built the reader on top of foliate-js — the rendering engine behind the popular Foliate GTK reader — vendored as a git submodule and wrapped in a custom runtime layer. This gives me tested EPUB/CBZ/MOBI/FB2 rendering with CSS pagination, reflowable and fixed-layout support, and EPUB CFI navigation.

Multi-Format Ebook Reader image
Multi-Format Ebook Reader image

The two rendering engines are:

  • FoliateEngine : Handles EPUB, MOBI, AZW, AZW3, FB2, CBZ, CBR. Uses the foliate-paginator web component with CSS grid pagination in a closed shadow DOM. The paginator container dimensions are determined by grid track sizing not iframe content, so layout is stable and measurable on the first frame with zero settle delay.
  • PDFJsEngine : Wraps pdfjs-dist v6 as a React component with multi-page canvas rendering, text layer, and annotation overlays. Supports zoom modes (custom, page-fit, width-fit) with mouse/pinch-center anchoring, and freehand pen drawing with coordinate-based text notes.

Format breakdown:

  • EPUB: OPF manifest, spine, NCX navigation parsed through the foliate-js EPUB runtime. On desktop, a Rust pre-parser streams the container.xml, OPF, and nav documents with quick-xml, then pre-decodes all HTML sections into a text cache — allowing the reader to skip zip.js entirely for text content, only lazy-loading it for embedded images.
  • MOBI/AZW/AZW3: Parsed via the foliate-js MOBI runtime (Palm Database format with PalmDoc and Mobipocket headers), with fflate for zlib decompression.
  • FB2: FictionBook XML format, parsed directly in the runtime.
  • CBZ: Standard ZIP of page images. CBR is converted to CBZ at import time using Rust's unrar-ng.
  • PDF: Rendered page-by-page via pdfjs-dist. Tauri commands handle file I/O natively, bypassing browser CORS restrictions.
Format breakdown: image
Format breakdown: image

## EPUB Pre-Parser (Rust Fast Path)

A detail I'm particularly proud of: when you open an EPUB on the desktop, Rust pre-decodes all XHTML content into a structured cache before React even mounts. The command:

  1. Streams container.xml (handling UTF-8/UTF-16 BOMs), extracts the rootfile path with percent-encoded fallbacks
  2. Parses the OPF for manifest/spine and the NCX/nav for table of contents, all using streaming quick-xml — no regex
  3. Pre-decodes every text section into a textCache (capped at 100 sections), along with a sizes map of compressed sizes for every zip entry

This runs on spawn_blocking for true parallelism with the WebView boot. The makeZipLoader bridge in the foliate-js runtime checks for this cache — if it exists, getEntries() returns immediately from the pre-decoded data without touching zip.js at all.

Neural TTS: Kokoro-82M + ONNX Runtime (Offline, 6 Voices)

Neural TTS: Kokoro-82M + ONNX Runtime (Offline, 6 Voices) image
Neural TTS: Kokoro-82M + ONNX Runtime (Offline, 6 Voices) image

I wanted text-to-speech without calling to ElevenLabs or Google. **Kokoro** is an MIT-licensed TTS model with ~82M parameters in ONNX format. I use the `kokoro-en` crate with the `misaki-lean` feature — a pure-Rust G2P phonemizer that eliminates the espeak-ng subprocess dependency.

The model (onnx-community/Kokoro-82M-ONNX) is downloaded from HuggingFace on demand, not bundled. Inference runs through the ort crate (ONNX Runtime bindings) on CPU — no GPU needed. The pipeline:

Text → misaki G2P phonemizer → Kokoro ONNX Model → Audio Samples → Streaming WAV → Audio Output

Six English voices are available: three female (af_bella, af_nicole, af_sarah) and three male (am_adam, am_michael, bm_george).

Audio is streamed via Tauri events as chunks are generated, with prewarming on desktop startup (mobile loads lazily to avoid ANR). The TTS model status is tracked through dedicated Tauri commands.

Encrypted P2P LAN Sync

This is the feature I'm most proud of. When you want to sync two Theorem instances, one device shows a QR code containing its IP, port, and an ephemeral X25519 public key. The other device scans it (or enters the code manually), and a pairing handshake begins.

The sync infrastructure has three layers:

  1. CORE: shared library with the crypto protocol, embedded HTTP server (axum), and persistence
  2. Command : Tauri command wrappers for pairing, pushing, and pulling data
  3. Daemon : standalone sidecar process that runs independently of the GUI, auto-syncing every 120 seconds via a control API on localhost:43936

Pairing flow:

Device A: Generates ephemeral X25519 keypair, embeds public key in QR
Device B: Scans QR, extracts (ip, port, host_public_key, nonce)
Device B → A: POST /pair with B's ephemeral public key (encrypted with session key)
Both: Compute shared secret via X25519 ECDH
Both: Derive ChaCha20-Poly1305 key via HKDF (salt = pairing nonce)
A → B: Pairing response with verified proof, both store symmetric key

Sync protocol:

  • Transport: HTTP/1.1 over TCP, fixed port 43935 (falls back to OS-assigned)
  • Encryption: ChaCha20-Poly1305 AEAD, random 12-byte nonce per message
  • Merge strategy: Last-writer-wins per domain, using settingsLastModifiedAt timestamps
  • Sync domains: books, annotations, collections, tombstones, vocabulary, RSS feeds/articles, settings, reading stats

No mDNS, no WebRTC, no cloud relay. Discovery is intentional — you choose when to pair by scanning a QR code on the same WiFi, not by broadcasting your presence to the network.

Reading Statistics

I built a full reading statistics system that tracks time spent, books completed, daily streaks, and yearly goals. The statistics page includes a 12-week reading heatmap and shareable stats cards because seeing your reading habit visualized is surprisingly motivating.

Reading Statistics image
Reading Statistics image

RSS Feed Aggregation

Theorem doubles as an RSS reader. It parses RSS 2.0, Atom, JSON Feed, and RDF format. A token-bucket rate limiter (2 req/s for feeds, 1 req/s for articles) prevents hammering servers. On desktop, feeds are fetched through Rust's native HTTP client; in the browser, a CORS proxy is used.

RSS Feed Aggregation image
RSS Feed Aggregation image

Vocabulary Builder with StarDict

I integrated the StarDict dictionary format you can bring your own dictionary file or download the prelinked dictionary that i converted to StarDict from wikitionary data . Long-press any word in the reader to see definitions, save terms with their meanings (deduplicated across sources), and review them later. Dictionaries are downloaded and extracted (tar.bz2/zip) via a dedicated Tauri command with progress reporting, then stored in IndexedDB (browser) or SQLite blobs (desktop).

Vocabulary Builder with StarDict image
Vocabulary Builder with StarDict image

Markdown Export

Every highlight and annotation can be exported as Markdown with proper YAML frontmatter, ready to drop into an Obsidian vault or any Knowledge Management System. The vault sync module creates per-book pages in a -books subdirectory and a consolidated theorem-vocabulary.md vocabulary page.

---
source: "The Pragmatic Programmer"
author: "Andy Hunt & Dave Thomas"
timestamp: 2026-05-12T14:30:00Z
page: 47
---

> “Invest regularly in your knowledge portfolio.”

My note: This is the software equivalent of compound interest. Spend 30 min a day learning something new.

Share Studio

A dedicated UI for sharing quotes as social media cards. Choose a template and color scheme, and export as PNG. Quotes can also be shared directly to X/Twitter via an intent link.

Share Studio image
Share Studio image

Persistence: Dual-Layer Storage

State management uses five Zustand stores, all persisted through a custom theoremPersistStorage abstraction that transparently routes between SQLite (rusqlite) on desktop and IndexedDB in the browser. Each store has versioned migrations:

StorePurposeCurrent Version
useUIStoreNavigation, sidebar, sync status1
useLibraryStoreBooks, collections, annotations, tombstones6
useSettingsStoreApp settings, reader settings, stats, TTS, sync6
useVocabularyStoreTerms, dictionaries, lookup cache5
useRssStoreFeeds, articles, current article (capped at 500, 30 days)1

Cover images are stored separately and restored on rehydration in batches of 24 to keep the persisted state lean.


The full source is on GitHub at github.com/fundaments-work/theorem. It's MIT licensed, v1.0 launched June 2026, and I'm actively developing it. If you're tired of cloud-dependent reading apps, give it a try — and open an issue if something doesn't work. You can also try the demo at https://app.theorem.fundaments.work/

Collaboration

Interested in working together?

Download my resume to review experience, project context, and technical depth.

Download Resume