Extract text from images, documents, and ZIP archives using Mistral OCR.
Upload a single file, a whole folder, or a ZIP archive; preview it in the
browser; and download the extracted text as .txt or, for several files, as a
.zip. Legacy Office documents are converted to PDF with LibreOffice before
extraction.
- Features
- Tech stack
- Quick start
- Configuration
- API reference
- Project structure
- Development
- Deployment
- Limitations
- Security and privacy
- Licence
- Single, multiple, folder, and ZIP uploads — drag and drop, or browse.
- Automatic conversion of
.doc,.docx,.ppt, and.pptxto PDF via LibreOffice before extraction. - In-browser preview for images, PDFs, and
.docx. - Download one result as
.txt, or several bundled into a.zip. - Contact form with reCAPTCHA verification.
- Safe archive handling — path-traversal (zip-slip) members are refused and expansion is size-capped against zip bombs.
- Zero-config local run — defaults to SQLite, so no database server is needed to get started.
| Layer | Technology |
|---|---|
| Backend | Python 3.12, Django 5.2 (LTS), Django REST Framework |
| Frontend | React 19, Vite 7, Tailwind CSS 3 |
| OCR | Mistral OCR API |
| Conversion | LibreOffice (headless) |
| Database | SQLite by default; MySQL optional |
| Serving | Gunicorn + WhiteNoise (backend), nginx (frontend) |
git clone <repository-url>
cd ocr-engine
cp .env.example .envEdit .env and set at least:
DJANGO_SECRET_KEY=<generate one, see below>
MISTRAL_API_KEY=<your Mistral API key>Generate a secret key with:
python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"Then:
docker compose up --build- Frontend: http://localhost:5173
- API: http://localhost:8000
DJANGO_SECRET_KEY and MISTRAL_API_KEY are the two variables Compose
requires — it stops with an explanatory message if either is missing, rather
than starting a stack that cannot work. Everything else has a working default.
RECAPTCHA_SECRET_KEY is optional: without it the service starts normally and
logs a warning, and only the contact form is unusable (it rejects submissions
with a clear message). Text extraction is unaffected.
Database migrations run automatically on container start, and the SQLite
database plus uploaded media persist in the backend-data volume.
You need Python 3.12+, Node.js 22+, and — only for .doc/.docx/.pptx
conversion — LibreOffice on your PATH.
Backend:
cd backend
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
cp ../.env.example .env # then set MISTRAL_API_KEY
python manage.py migrate
python manage.py runserverFrontend, in a second terminal:
cd frontend
npm install
cp ../.env.example .env
npm run dev
DJANGO_DEBUG=Truemust be set for local development — it is already set in.env.example. With debug off, the app deliberately refuses to start unless real production secrets are present.
Every variable below is read by the code. Copy .env.example and fill it in.
| Variable | Description |
|---|---|
DJANGO_SECRET_KEY |
Django signing key. Startup fails without it when debug is off. |
MISTRAL_API_KEY |
Mistral OCR API key. Startup fails without it when debug is off. |
DJANGO_ALLOWED_HOSTS |
Comma-separated hostnames. Required when debug is off; * is rejected. |
RECAPTCHA_SECRET_KEY is optional: without it the app starts and logs a
warning, and only the contact form stops working.
| Variable | Default | Description |
|---|---|---|
DJANGO_DEBUG |
False |
Debug mode. Never enable in production. |
DJANGO_CORS_ALLOWED_ORIGINS |
localhost:5173 in debug, else empty | Browser origins allowed to call the API. |
DJANGO_CORS_ALLOW_CREDENTIALS |
False |
Send cookies cross-origin. |
DJANGO_CSRF_TRUSTED_ORIGINS |
(empty) | Origins trusted for CSRF. |
DJANGO_TIME_ZONE |
UTC |
Server time zone. |
DJANGO_LOG_LEVEL |
INFO |
Root log level. |
| Variable | Default | Description |
|---|---|---|
DB_ENGINE |
(empty → SQLite) | e.g. django.db.backends.mysql. |
DB_NAME |
— | Required when DB_ENGINE is set. |
DB_USER, DB_PASSWORD, DB_HOST, DB_PORT |
(empty) | Standard connection details. |
SQLITE_PATH |
backend/db.sqlite3 |
SQLite file location when DB_ENGINE is empty. |
MEDIA_ROOT |
backend/media |
Where uploaded media is written. |
For MySQL, also pip install -r backend/requirements-mysql.txt.
| Variable | Default | Description |
|---|---|---|
VITE_API_URL |
http://localhost:8000 |
Backend origin only; the /ocr prefix is added by the client. |
VITE_RECAPTCHA_SITE_KEY |
(empty) | reCAPTCHA v2 site key. Without it the contact form is disabled with a visible notice. |
Vite inlines VITE_* variables at build time, so they must be set when
building, not when running.
| Variable | Default | Description |
|---|---|---|
MISTRAL_OCR_MODEL |
mistral-ocr-latest |
OCR model identifier. |
MAX_UPLOAD_SIZE |
209715200 (200 MB) |
Largest accepted upload. |
MAX_ARCHIVE_EXTRACTED_SIZE |
524288000 (500 MB) |
Cap on total ZIP expansion. |
FILE_UPLOAD_MAX_MEMORY_SIZE |
10485760 (10 MB) |
Above this, uploads spill to a temp file. |
DATA_UPLOAD_MAX_MEMORY_SIZE |
5242880 (5 MB) |
Cap on non-file request bodies. |
Ignored while DJANGO_DEBUG is on.
| Variable | Default | Description |
|---|---|---|
DJANGO_SECURE_SSL_REDIRECT |
True |
Redirect HTTP to HTTPS. |
DJANGO_SECURE_HSTS_SECONDS |
31536000 |
HSTS max-age. Set 0 while testing TLS. |
DJANGO_USE_X_FORWARDED_PROTO |
False |
Trust X-Forwarded-Proto. Only behind a proxy that strips it. |
Base URL: http://localhost:8000. All app routes are mounted under /ocr/.
There is no authentication.
Extract text from one or more files. Body: multipart/form-data with one or
more file parts.
Accepted content types: application/pdf, image/jpeg, image/jpg,
image/png, application/msword, application/vnd.ms-powerpoint,
application/vnd.openxmlformats-officedocument.wordprocessingml.document,
application/vnd.openxmlformats-officedocument.presentationml.presentation,
application/zip, application/x-zip-compressed.
curl -F "file=@scan.pdf" http://localhost:8000/ocr/upload/Single file → 200:
{ "Transcription": "--- Page 1 ---\nExtracted text…" }Multiple files or a ZIP → 200, keyed by filename. Files converted to PDF
appear under their new .pdf name:
{
"results": {
"invoice.pdf": "--- Page 1 ---\n…",
"notes.pdf": "--- Page 1 ---\n…"
}
}Within a batch or archive, a file that fails individually gets an
"[error] …" string as its value rather than aborting the whole request.
Error responses carry {"error": "..."}, except validation failures which
return DRF's field-error object:
| Status | Meaning |
|---|---|
400 |
No file, unsupported content type, or a corrupt/empty archive. |
413 |
Upload exceeds MAX_UPLOAD_SIZE, or the archive expands past MAX_ARCHIVE_EXTRACTED_SIZE. |
502 |
LibreOffice conversion failed, or the OCR provider errored. |
503 |
Server misconfigured — no MISTRAL_API_KEY, or LibreOffice not installed. |
Store a contact-form submission. Body: application/json.
{
"first_name": "Ada",
"last_name": "Lovelace",
"company": "Analytical Engines",
"email": "ada@example.com",
"project": ["OCR Engine", "Resume Parser"],
"message": "Please get in touch.",
"recaptcha_token": "<token from the reCAPTCHA widget>"
}All fields are required; project must hold at least one entry and is stored
comma-separated.
201→{"message": "Form submitted successfully"}400→ field errors, including a failed or unconfigured reCAPTCHA check.
This endpoint stores personal data. See SECURITY.md.
The standard Django admin, exposing stored contact submissions. Create a user
with python manage.py createsuperuser. Restrict or remove it in production.
ocr-engine/
├── backend/
│ ├── OCRengine/ # Django project (settings, URLs, WSGI/ASGI)
│ ├── appOCR/
│ │ ├── migrations/
│ │ ├── admin.py # Admin registration for submissions
│ │ ├── exceptions.py # Domain errors carrying HTTP status codes
│ │ ├── models.py # OCRAppFormSubmission
│ │ ├── serializers.py # Request validation
│ │ ├── services.py # Business logic: conversion, archives, batching
│ │ ├── tests.py # 26 tests
│ │ ├── urls.py
│ │ ├── utils.py # LibreOffice + Mistral integration
│ │ └── views.py # Thin HTTP layer
│ ├── .pylintrc
│ ├── Dockerfile # Multi-stage, non-root, Gunicorn
│ ├── manage.py
│ ├── requirements.txt
│ ├── requirements-dev.txt
│ └── requirements-mysql.txt
├── frontend/
│ ├── public/
│ ├── src/
│ │ ├── components/ # Presentational components
│ │ ├── constants/ # Shared file and product constants
│ │ ├── hooks/ # useFileManager, useExtraction, useContactForm
│ │ ├── pages/Home.jsx # Workspace page
│ │ ├── services/ # API client
│ │ └── utils/ # Text rendering and download helpers
│ ├── Dockerfile # Multi-stage, non-root nginx
│ ├── eslint.config.js
│ ├── nginx.conf
│ └── vite.config.js
├── .github/workflows/ci.yml
├── .env.example
├── docker-compose.yml
├── CONTRIBUTING.md
├── LICENSE
└── SECURITY.md
appOCR and OCRengine do not follow PEP 8 module naming. The names are
recorded in the migration history and the appocr_* table names, so renaming
them would break existing deployments; the lint rule is relaxed in
backend/.pylintrc instead.
See CONTRIBUTING.md for the full workflow and the exact commands CI runs. In short:
# Backend, from backend/ with DJANGO_DEBUG=1
pylint OCRengine appOCR manage.py
python manage.py test
# Frontend, from frontend/
npm run lint
npm test
npm run buildBoth Dockerfiles are multi-stage, run as a non-root user, and use production servers — Gunicorn for the backend, nginx for the frontend. Neither runs a development server.
The backend image collects static files at build time and applies migrations on container start. The frontend image serves the built bundle on port 8080 inside the container (mapped to 5173 by Compose).
Work through the safe-deployment checklist before exposing an instance publicly.
Known and deliberate, so you can plan around them:
- No authentication or rate limiting. Every endpoint is public, and each upload costs a paid Mistral API call. Put an authenticating, rate-limiting proxy in front of any public deployment.
- Extraction is synchronous. A large document holds an HTTP connection open for its whole duration; Gunicorn's timeout is set to 300s. There is no job queue or progress endpoint — the frontend's progress bar reflects files completed in a client-side loop, not server-side progress.
- Multi-file uploads are processed one request at a time by the frontend, sequentially, so a large batch is slow.
- Nested archives are not unpacked. A ZIP inside a ZIP is skipped.
.doc/.docx/.pptxneed LibreOffice. Without it those uploads fail with a503; every other format still works.- Preview support is narrower than extraction support. Images, PDFs, and
.docxpreview in-browser;.docand.pptxextract but show a placeholder. - Uploaded files are sent to a third-party API and are not retained locally after processing.
- No retention policy for contact-form submissions.
- The frontend's declared
webkitdirectoryfolder upload is Chromium-only; other browsers fall back to file selection.
- Report vulnerabilities privately — see SECURITY.md.
- The contact form stores personal data, and uploaded documents are sent to Mistral. Both are documented in SECURITY.md; if you run a public instance, tell your users.
Licensed under the GNU Affero General Public License v3.0 — see LICENSE.
The AGPL's network clause matters here: if you run a modified version of this software as a network service, you must offer its source to users of that service.