All projects

Live · Team of 4 · 2025–26

ConTeX

Photograph handwriting or drop in a PDF and get LaTeX back, compiled to a PDF preview in your browser.

Role
Lead developer — edge port, security audit, test suites, CI/CD
Team
Team of 4
Timeline
May 2025 – Sep 2026
Status
Live
Stack
  • JavaScript
  • Cloudflare Workers
  • Gemini API
  • WebAssembly
  • Firebase

TL;DR

Problem
Turning handwritten or printed maths into LaTeX by hand is slow, and the tools that do it usually need a paid account, a local TeX install or a server that compiles for you.
What I built
A free web app on Cloudflare’s edge. A Worker sends each page to Gemini with a prompt the client can’t touch; the browser compiles the LaTeX itself with pdfTeX in WebAssembly, and falls back to on-device OCR when the AI is unavailable.
Result
Live at contex.rhengtl.tech with 1,197 automated checks across 10 suites, a 34 ms warm in-browser compile, and a pre-launch security audit that closed a prompt-injection hole and a TeX file-read bypass.
Input and output of a ConTeX conversion. Input: a handwritten page reading "The energy of a particle is given by E = mc², where c is the speed of light in vacuum." Output: the LaTeX ConTeX returned, with the sentence as text and the equation as display math.

ConTeX turns a photographed or scanned page — handwriting, printed text, equations — into LaTeX, then compiles it to a PDF preview right in the browser. It began as a team project with the same four people as EarPrompt, as a Flask app deployed with Docker. This page is about the version that’s live now: I rebuilt it on Cloudflare’s edge in 2026, and every commit to that rebuild, its tests and its CI pipeline is mine.

The problem

Students and teachers write maths by hand, but documents are expected in LaTeX. Retyping a page of equations is slow and error-prone. The existing shortcuts tend to need a paid account, a heavy local TeX installation, or a server that compiles documents on your behalf.

The constraints for ConTeX:

  • Free to run. It has to stay inside free tiers, including a Worker CPU budget of 10 ms per request.
  • No compile server. Compiling LaTeX should cost nothing per user.
  • Safe to expose. A public endpoint that calls an LLM on our key must not become a free, general-purpose LLM for anyone who finds it.
  • Honest when the AI is down. If the model is unavailable, the user should still get a result, and know it was produced differently.

How it works

Browser

  • Input File picker, drag-and-drop, camera or a drawing canvas; PDFs are split into single pages.
  • Validate and compile Unsafe TeX is refused, then pdfTeX (SwiftLaTeX, WebAssembly) compiles against a self-hosted TeX Live subset.
  • Preview pdf.js renders the compiled PDF on a canvas; the .tex downloads unchanged.
  • Offline fallback Tesseract OCR and an INT8 ONNX formula recognizer, running locally — nothing is sent anywhere.

Cloudflare Worker

  • Owns the prompt Takes only raw page bytes, uploads them to Gemini’s Files API and writes the whole model request itself.
  • Model chain Tries a chain of models in order and keeps per-model outage records in KV.
  • Sessions and history Signed session cookie, sign-in via Identity Toolkit, saved conversions scoped by user ID.
  • Protection Per-caller rate limits in a Durable Object, and a strict Content-Security-Policy on every response.

Google services

  • Gemini Reads the page and returns LaTeX.
  • Firebase Accounts and saved history. Firestore rules deny every direct client read and write.
Fig. 1 — Where each part runs. The heavy work — compiling and the offline fallback — happens in the visitor’s browser, so the server stays small and free.

Two decisions explain most of the design. The heavy work runs in the browser, so there is no compile queue, no server timeout and no gigabyte-sized TeX install to host. And static assets never invoke the Worker: the TeX tree, the WebAssembly engine and the model weights are served directly by the platform, free and unmetered. A page load costs two Worker requests; an N-page conversion costs N + 1, against a free allowance of 100,000 a day.

The ConTeX result page for a converted handwritten page: download, copy and open-PDF buttons, the compiled PDF preview showing the sentence and the equation E = mc², and the LaTeX source panel underneath.
Fig. 2 — A converted page: the compiled PDF preview and the LaTeX source, both produced without a compile server.

My contribution

  • The edge rebuild. I ported the Flask app to a Cloudflare Worker plus browser modules in stages. Each stage had its own parity suite against the Python version, which stayed the source of truth until the port was complete.
  • In-browser LaTeX. I made the pdfTeX engine work from static hosting and built the TeX Live subset it compiles against.
  • The security audit, and the fixes for what it found (below).
  • Tests and delivery. The test suites, including browser end-to-end runs, and a GitHub Actions pipeline that rebuilds the model assets, runs every suite, deploys and then checks the live site answers.

Key technical challenges

A prompt injection hidden in a performance shortcut

To stay inside the 10 ms CPU budget, the Worker streamed the client’s base64 image straight into the JSON request it sent to Gemini, without reading it. Nothing checked that the body really was base64, so a client could close the JSON string, append its own instruction, and reopen it — and the result still parsed. That made the endpoint a free LLM on our key.

Scanning for quotes would have fixed it, but I measured that at 19 ms for a 25 MB upload, nearly double the budget. So I took the client’s bytes out of the JSON entirely: they’re piped as an opaque body into Gemini’s Files API, and the Worker writes 100% of the model request around the resulting file reference.

Trade-off: about 200 ms slower on a small image. In exchange, nothing the client sends is ever parsed as JSON, and uploads are 25% smaller because base64 encoding is gone.

Compiling LaTeX without a server, safely

The open-source engine hung on its first compile from any page below the site root, because it loaded its worker from a relative path. Static hosting also can’t speak the engine’s package protocol, and the engine requests fonts without file extensions. I patched the engine for the first two and generated 753 extensionless font aliases for the third.

Compiling untrusted TeX is also a security problem: TeX can read files. The audit found that the validator’s patterns missed \openin1=..., the ordinary form, because a word boundary doesn’t fall between a letter and a digit. Six constructs shared that hole. A second pass found that pdfTeX’s own file primitives, such as \pdffiledump, were missing entirely; one of them typeset a test file’s contents, as hex, into the PDF. Both were fixed in the browser validator and back-ported to the Python version, and all 26 real benchmark documents still pass.

Moving the offline models into the browser without losing accuracy

The fallback runs two recognisers locally: Tesseract for text and an INT8 formula model (pix2text-mfr) through ONNX Runtime Web. Both had to match the Python pipeline they replaced, and both problems were found by measuring rather than by reading code.

The formula model expects images downscaled with Pillow’s bicubic filter. A canvas resize is a different, browser-dependent filter, and a differently filtered image is a different input, so I ported Pillow’s resampler to JavaScript. Then the first benchmark run scored 69% and looked like a failed port. It wasn’t: the suite compared raw strings, while the benchmark’s scorer normalises spacing (E = m c ^ { 2 } and E = mc^2 are the same answer). With the benchmark’s own scorer, the browser port came out slightly ahead of the Python reference (Results, below). Tesseract is the same engine compiled to WebAssembly with the same language file, self-hosted so the offline path never contacts a third party.

Never quietly producing a worse document

If the AI is unavailable when you press Convert, ConTeX asks before using the offline path. If the AI fails partway through a multi-page document, it finishes the remaining pages locally and says so on the result. A dedicated test drives the whole conversion against a Gemini that is down, rate-limited, or dies on page three.

A ConTeX result converted without AI: a notice reads 'This document was converted without AI. It was converted in this browser instead, so quality may be lower', with notes that two lines were recognised with low confidence, above the compiled preview.
Fig. 3 — The fallback path, captured during an AI outage: the result says it was converted locally and flags low-confidence lines instead of hiding them.

Results

Automated verification
Suite Checks
Security: the Gemini endpoint cannot be steered165
Validation and compile parity226
Multi-page merge, byte-identical to the Python version211
Sessions, sign-in, history and deletion134
Guest history, end to end in a browser63
Input: file picker, drag-and-drop, camera, canvas36
The whole offline pipeline115
The app end to end, through its own front door49
Formula recogniser on the 75-image benchmark16
Python suite182

1,197 checks in total, from the repository’s last documented run. The merge suite reproduces all 33 page groups — including the 26 real benchmark outputs merged in pairs and triples — character for character.

Offline fallback against the Python pipeline, same frozen benchmark
Measure Browser Python
Formulas: character accuracy91.88%90.26%
Formulas: exact (of 75)62 (83%)60 (80%)
Pages: text accuracy96.53%96.53%
Pages: structure100.00%100.00%
Pages: maths90.49%77.87%

Page scores are means over the whole fallback pipeline. The benchmark is synthetic pages with known ground truth, so these measure the port, not real-world handwriting. The maths gap is one image the browser segments into two expressions where Python merges them.

In-browser LaTeX compile
Case Desktop Pixel 7 viewport, 4× CPU throttle
Cold compile (engine + 9.9 MB format)291 ms311 ms
Warm compile34–39 ms38–41 ms
Large document (26 pages)79 ms85 ms

Throttling was emulated, not measured on a real low-end phone.

Limitations and what I’d do next

  • The AI runs on Gemini’s free tier. The app says so before upload, because Google may use free-tier input for training. A paid tier would remove that caveat.
  • The offline fallback is noticeably weaker on handwriting (Fig. 3). It’s a safety net, not a replacement.
  • Some packages are deliberately absent — hyperref, tikz and cancel aren’t in the TeX subset. The preview drops them and says which, while the downloaded .tex stays exactly as the model wrote it.
  • The benchmark is synthetic. It proves the port is faithful, not how well either path reads real handwriting.
  • Next: test on real low-end hardware rather than emulation, score the AI path on the same frozen corpus the fallback already uses, and add a set of real handwritten pages.