Drop a .docx. Get compilable LaTeX. Open in Overleaf in one click.
Quick Start · Architecture · Features · API · Deploy · Roadmap
Academic writers draft in Word. Conferences demand LaTeX. The gap between them eats hours.
CoreTex is a compiler-style pipeline that converts Microsoft Word documents into compilable LaTeX with academic-grade fidelity. It targets IEEE, ACM, and Springer conference submissions — but works for any LaTeX writing.
| Hand re-typing | Pandoc CLI | mammoth.js | CoreTex | |
|---|---|---|---|---|
| Equations (OMML) | Yes | Partial | No | Yes (batched) |
| Unicode math (∈ ℝ X̃ α∇∑) → math mode | Yes | No | No | Yes (~140 glyphs) |
| Tables + alignment | Yes | Partial | No | Yes |
| Citations detected | Partial | No | No | Yes |
| IEEE / ACM / Springer templates | Yes | No | No | Yes |
| i18n list detection (FR/DE/ES/IT/PT) | Yes | Partial | No | Yes |
| Compile check + error line | n/a | No | No | Yes |
| Overleaf one-click | No | No | No | Yes |
| Decompression-bomb hardening | n/a | No | No | Yes |
| Web UI | No | No | No | Yes |
| Time to convert 20-page paper | ~3 hours | ~10 min¹ | ~10 min¹ | ~3 seconds |
¹ Plus manual cleanup, structural fixes, template porting, etc.
CoreTex follows a strict compiler-style Intermediate Representation (IR) pattern. The parser never produces LaTeX strings; the renderer never reads Word XML. The IR is the only shared contract — making each layer independently testable.
flowchart LR
subgraph Frontend["Frontend (Vercel)"]
UI[React + CodeMirror]
end
subgraph API["FastAPI Service (Railway)"]
R[Routes /convert /status /download /temp]
end
subgraph Queue["RQ Worker (Railway)"]
direction TB
P[Parser<br/>OOXML → IR]
H1[Equation Handler<br/>Pandoc subprocess]
H2[Image Handler<br/>Pillow compress]
H3[Table Handler<br/>column-spec]
RD[Renderer<br/>IR → LaTeX]
CC[Compile Check<br/>pdflatex]
P --> H1 --> H2 --> H3 --> RD --> CC
end
subgraph Cache["Redis"]
Jobs[Job state + result]
Temp[5-min temp URLs<br/>for Overleaf snip_uri]
Figs[Compressed figures]
end
UI -->|POST .docx| R
R -->|enqueue| Jobs
Jobs -->|dequeue| P
CC -->|ConversionResult| Jobs
Jobs --> R
R -->|.tex or .zip| UI
R -.->|cache .tex| Temp
UI -.->|snip_uri| Overleaf[(Overleaf)]
Overleaf -.->|fetch| Temp
classDef frontend fill:#22d3ee,stroke:#0e7490,color:#06141d,stroke-width:2px
classDef backend fill:#10b981,stroke:#047857,color:#06141d,stroke-width:2px
classDef worker fill:#f59e0b,stroke:#b45309,color:#06141d,stroke-width:2px
classDef cache fill:#ef4444,stroke:#991b1b,color:#fff,stroke-width:2px
class UI frontend
class R backend
class P,H1,H2,H3,RD,CC worker
class Jobs,Temp,Figs cache
| # | Layer | Purpose |
|---|---|---|
| 1 | Ingestion (FastAPI) | MIME magic-byte validation, 20 MB cap, BytesIO only, enqueue RQ job |
| 2 | Surgical Parser (python-docx + lxml) | Walks OOXML tree → typed IR nodes |
| 3 | IR Schema (Pydantic v2) | FROZEN contract: 10 node types |
| 4 | Specialist Handlers | Equations (Pandoc), images (Pillow), tables (column alignment) |
| 5 | IR Renderer (pure Python + Jinja2) | IR → LaTeX with smart preamble injection |
| 6 | Bibliography | Managed Word sources → references.bib + \cite; un-managed → [CITATION] marker |
| 7 | Packager | .tex or .zip + Overleaf temp URL + pdflatex check |
sequenceDiagram
autonumber
participant U as Browser
participant A as FastAPI
participant R as Redis
participant W as RQ Worker
participant P as pdflatex
U->>A: POST /convert (.docx)
A->>A: Magic-byte check + 20 MB cap
A->>R: enqueue(job_id)
A-->>U: {job_id, status: queued}
loop every 2s
U->>A: GET /status/{job_id}
A->>R: fetch job state
R-->>A: status
A-->>U: {status, result_summary?}
end
R-->>W: dequeue
W->>W: parse_docx → IRDocument
W->>W: hydrate equations (1 batched Pandoc call)
W->>W: compress images (Pillow, bomb-capped, EXIF preserved)
W->>W: render → LaTeX (smart preamble, ~140 unicode math glyphs)
W->>P: pdflatex (-no-shell-escape, openin_any=p, 30s)
P-->>W: (ok, error_line)
W->>R: store ConversionResult + per-file figure keys
U->>A: GET /download/{job_id}
A->>R: fetch result + assemble zip from figure keys
A->>R: SETEX temp:{id} TTL=5min
A-->>U: .tex or .zip + X-Overleaf-Temp-URL
U->>U: Open in Overleaf →
Note over U: overleaf.com/docs?snip_uri=https://your-api/temp/{id}
|
|
|
The renderer walks the IR and only emits the packages the document actually uses — |
|
Prerequisites: Docker Desktop + Node.js 20+
# 1. Clone
git clone https://github.com/TheClazer/CoreTex.git
cd CoreTex
# 2. Backend (Redis + API + RQ worker + TeX Live, all in Docker)
docker compose up --build
# 3. Frontend (new terminal)
cd frontend
npm install
npm run devOpen http://localhost:5173, drop a .docx, pick a template, hit Convert →.
First Docker build pulls ~1.5 GB of TeX Live for the compile check. Skip it with
docker compose build --build-arg INSTALL_TEXLIVE=0for fast iteration.
# Backend — unit + integration suite (escape, parser, renderer,
# golden-doc regression on .docx fixtures, HTTP integration, v2 features)
pytest tests/ -v
# Frontend — Vitest + tsc
cd frontend && npm testBase URL: http://localhost:8000 (dev) · your Railway domain (prod)
| Method | Path | Purpose |
|---|---|---|
POST |
/convert?template=<article|ieee|acm|springer|beamer> |
Upload .docx, returns {job_id, status: "queued"} |
GET |
/status/{job_id} |
Polled every 2 s. Returns {status, result_summary?} with citation/warning counts, compile error line. |
GET |
/download/{job_id} |
.tex (text/plain) or .zip (with figures/ + references.bib). Adds X-Overleaf-Temp-URL header. |
GET |
/temp/{job_id}[.tex|.zip] |
Public 5-min snip URL — Overleaf's snip_uri target. Suffix lets Overleaf detect the type from the URL. |
GET |
/auth/providers |
Returns which auth methods are configured. |
POST |
/auth/signup |
{email, password, display_name?} → JWT. |
POST |
/auth/login |
{email, password} → JWT. |
GET |
/auth/me |
Current user (requires Bearer token). |
GET |
/auth/{google|github}/start |
302 → OAuth provider. |
GET |
/auth/{google|github}/callback |
OAuth return path; redirects to frontend with #token=.... |
GET |
/history |
List of the user's conversions (paginated). |
GET |
/history/{id} |
One conversion's metadata + .tex + figure filenames. |
GET |
/history/{id}/download |
Re-download the .tex or .zip. |
DELETE |
/history/{id} |
Remove a conversion from history. |
Example: full conversion flow
# 1. Submit
JOB=$(curl -s -X POST 'http://localhost:8000/convert?template=ieee' \
-F '[email protected]' | jq -r .job_id)
# 2. Poll
while true; do
STATUS=$(curl -s "http://localhost:8000/status/$JOB" | jq -r .status)
echo "$STATUS"
[[ "$STATUS" == "finished" || "$STATUS" == "failed" ]] && break
sleep 2
done
# 3. Download
curl -OJ "http://localhost:8000/download/$JOB"CoreTex/
├── app/
│ ├── main.py FastAPI entry
│ ├── config.py Pydantic Settings
│ ├── api/routes.py convert / status / download / temp
│ ├── api/history_routes.py user conversion history
│ ├── queue/worker.py RQ orchestrator
│ ├── storage.py figure store (Redis default, S3/R2 optional)
│ ├── converter/
│ │ ├── ir_schema.py FROZEN Pydantic IR (10 nodes)
│ │ ├── parser.py OOXML → IRDocument
│ │ ├── renderer.py IRDocument → LaTeX
│ │ ├── bibliography.py Word sources → references.bib
│ │ ├── run_merger.py adjacent-run merging
│ │ ├── style_map.py custom Word style mapping
│ │ ├── escape.py reserved + Unicode
│ │ ├── compile_check.py pdflatex + error parsing
│ │ └── handlers/
│ │ ├── equation_handler.py OMML → LaTeX via Pandoc (+ direct fallback)
│ │ ├── omml_direct.py Pandoc-free OMML → LaTeX
│ │ ├── image_handler.py Pillow compression
│ │ └── table_handler.py column-spec
│ └── templates/ article / ieee / acm / springer / beamer
├── frontend/ React + Vite + TS
│ └── src/
│ ├── App.tsx
│ ├── hooks/useConversion.ts Upload → poll → download
│ └── components/ UploadZone, LatexEditor, …
├── tests/ unit + integration + golden-doc + v2
├── .github/workflows/ci.yml pytest + ruff + vitest + tsc
├── Dockerfile + docker-compose.yml
├── railway.toml Backend deploy
├── frontend/vercel.json Frontend deploy
└── DEPLOY.md Step-by-step deploy walkthrough
CoreTex is built for Railway (backend + worker + Redis) + Vercel (frontend).
Follow the step-by-step walkthrough in DEPLOY.md — every manual step is marked → You: so you know what's automatic vs what needs your attention.
Overleaf integration requires public deployment. When running locally, Overleaf's servers can't reach your
localhost, so the "Open in Overleaf" button needs the backend to be deployed. Use the download button locally; the Overleaf button activates once you're on Railway.
Why a custom renderer instead of Pandoc end-to-end?
Pandoc adds its own structural commands (\tightlist, custom list macros, heading style resets) that collide with the IR renderer's output, producing duplicate or contradictory formatting that frequently fails to compile.
Pandoc is invoked only in the equation handler, on individual OMML fragments. Document structure is 100% custom-rendered.
Why python-docx + lxml instead of mammoth.js?
mammoth.js is a Node.js library built for Word → HTML. It exposes no access to OMML equations, paragraph properties, citation XML fields, or run structure. Using it would require running a Node subprocess from Python — and we'd still lack equation support. python-docx + lxml gives full OOXML access in-process.
Why is the IR schema frozen?
The parser writes IR; the renderer reads IR. If anyone renames a field mid-project without coordination, the renderer breaks silently. Freezing the schema after Week 1 — and requiring an explicit schema/<change> branch + team sign-off — ensures Pydantic surfaces drift as a ValidationError rather than a silent corruption.
Why not back-map LaTeX errors to the source Word paragraph?
Mapping pdflatex errors back through the IR to the original Word paragraph is extremely complex (LaTeX line numbers don't correspond to IR node indices). Practical alternative: surface the LaTeX line number in the warnings panel and have CodeMirror scroll to + highlight it. Users can fix there or open the document in Overleaf.
Why convert Unicode math glyphs at the escape layer instead of in math mode upstream?
Authors paste characters like ℝ, ∈, X̃, α∇∑ into normal text runs without ever opening Word's equation editor. Those characters reach the parser as paragraph text — not as <m:oMath> — so the equation handler never sees them. Catching them at the escape layer means a single pass over every text string is enough; consecutive glyphs merge into a single $…$ region with ^a^b^c → ^{abc} grouping so pdflatex doesn't raise "double superscript".
The renderer also detects when any text run contains a math glyph and auto-injects \usepackage{amssymb} so \mathbb{R} resolves. Documents without math glyphs don't pull in the package.
Why batch all equations through a single Pandoc call?
Pandoc's startup cost is ~400 ms on a warm machine. A paper with 150 equations would block the worker for ~60 seconds if each fired its own subprocess. We instead synthesize a single .docx with N paragraphs (one per equation), each sandwiched between unique ASCII sentinels, then call Pandoc once and split the output back into per-equation chunks by regex. A 150-equation paper converts in ~0.6 s.
Why raw bytes in Redis instead of pickled artefacts?
pickle.loads on untrusted Redis data is an RCE primitive. Even though Redis is internal, treating that boundary as trusted is the same mistake that broke a thousand other systems. Each figure is stored as a separate raw-byte key (figures:{job_id}:f:{name}) alongside a newline-delimited manifest of filenames. No Python-specific wrapper, no deserialisation risk, and individual figures can be looked up without loading the whole dict.
| Area | Limitation | Tracked in |
|---|---|---|
| Citations | v2: managed Word sources → references.bib + \cite; un-managed citations fall back to plain text |
shipped |
| Tables | Merged-cell rendering uses \multicolumn only (no row spans) |
v3 roadmap |
| Equations | Pandoc gives best fidelity; without it a built-in direct OMML→LaTeX fallback covers common constructs (fractions, scripts, radicals, n-ary ops, Greek/symbols) | shipped (v2) |
| Compile errors | LaTeX line number is surfaced; no back-mapping to original Word paragraph | bible §6 |
| Tracked changes | Revision markup stripped; final text only | bible §9 |
| Resume-style layouts | Per-word formatting + tabs produce verbose output | bible §9 |
| Area | Trade-off | Upgrade path |
|---|---|---|
| Upload size | Hard 20 MB cap | Set MAX_FILE_SIZE_MB; bump Railway RAM |
| Worker count | Single RQ worker blocks under load | Set numReplicas on the worker service (implemented, v2) |
| Figure storage | Redis by default (5-min TTL, ~50 MB ceiling) | FIGURE_STORAGE=s3 for S3 / Cloudflare R2 (implemented, v2) |
| Upload memory | ~5× duplication at peak (HTTP → buffer → RQ → Redis → worker) | Presigned PUT to S3, pass key through RQ |
| TeX Live image | 1.5 GB Docker layer; ~30 s cold start | Pre-warm with min-replicas, or INSTALL_TEXLIVE=0 |
See DEPLOY.md → Scaling constraints for the full upgrade-path discussion.
Full feature scope (v1 vs v2) lives in word_latex_bible.pdf §9.
gantt
title CoreTex roadmap
dateFormat YYYY-MM-DD
axisFormat %b
section v1 (shipped)
IR schema freeze :done, 2026-01-01, 7d
Parser + Renderer :done, 2026-01-08, 14d
Handlers + Templates :done, 2026-01-22, 14d
Compile check + UI :done, 2026-02-05, 14d
Deploy + Docs :done, 2026-02-19, 7d
section v1.1 (shipped — hardening)
Unicode math escape (~140) :done, 2026-03-01, 5d
Combining diacritics :done, 2026-03-06, 2d
Batched Pandoc :done, 2026-03-08, 2d
Pillow bomb cap + EXIF :done, 2026-03-10, 1d
pdflatex sandbox flags :done, 2026-03-11, 1d
i18n list detection (6 locales) :done, 2026-03-12, 2d
Redis raw-byte figures :done, 2026-03-14, 1d
section v2 (shipped)
Full BibTeX extraction :done, 2026-04-01, 21d
Beamer slides template :done, 2026-04-22, 14d
Direct OMML parser :done, 2026-05-06, 21d
Run-merger optimisation :done, 2026-05-27, 7d
Style mapping config :done, 2026-06-03, 14d
S3 / R2 figure storage :done, 2026-06-17, 7d
Worker autoscale on Railway :done, 2026-06-24, 5d
PRs welcome. Please follow the bible's branch conventions:
feature/<area>-<short-desc>— new functionalityfix/<area>-<short-desc>— bug fixesschema/<change-desc>— IR schema changes (requires team sign-off)
Every PR must:
- Keep the backend test suite passing (
pytest tests/) - Keep frontend tests passing (
npm test) - Pass
ruff check+tsc --noEmit - Not regress on any golden doc in
tests/golden/
CI enforces all four — see .github/workflows/ci.yml.
Distributed under the MIT License. See LICENSE for full text.
Copyright (c) 2026 Rayyan Shaikh and CoreTex contributors
Built around the principles laid out in word_latex_bible.pdf v1.0 —
a compiler-style IR pipeline, schema-first design, and per-layer ownership.
Standing on the shoulders of: FastAPI · python-docx · Pandoc · CodeMirror · Pillow · Overleaf · TeX Live
Star this repo if CoreTex saved you time.
Built by @TheClazer