An automated PDF remediation tool from the CUNY AI Lab that converts uploaded PDFs into accessible, PDF/UA-1 compliant documents.
Upload a PDF and the app classifies it, runs OCR if needed, extracts structure, generates alt text, writes PDF/UA tags, and validates the result against veraPDF. Documents that can't be fully remediated automatically are flagged for manual review.
- Automatic classification of digital, mixed, and scanned PDFs
- OCR with language auto-detection via OCRmyPDF and Tesseract
- Structure extraction via Docling, with optional remote
docling-servefor GPU acceleration - LLM-generated alt text for figures, with decorative-image detection
- LLM-assisted semantic tagging for tables, forms, reading order, and grounded text
- Deterministic PDF/UA tag writing via pikepdf
- Compliance gating with veraPDF PDF/UA-1 validation
- Fidelity checks for text drift, reading order, table coverage, and form labels
- CUNY sign-in through Doorway; each person sees only their own jobs, and model use counts against their own Gateway budget
| Layer | Technology |
|---|---|
| Backend | Python 3.12, FastAPI, SQLAlchemy (async SQLite) |
| Frontend | React, TypeScript, Vite, Tailwind CSS 4, TanStack Query |
| PDF Processing | pikepdf, OCRmyPDF, Ghostscript, Poppler, QPDF |
| Structure Extraction | Docling (local or docling-serve) |
| Semantic Analysis | Open-weight vision models through the CAIL Gateway |
| Validation | veraPDF |
- Python 3.12+ and uv
- Bun
- Ghostscript
- OCRmyPDF
- Tesseract
- Poppler (
pdftoppm) - veraPDF and a Java runtime
- A CAIL Gateway API key
On macOS, install system dependencies via Homebrew. On Debian/Ubuntu, install ghostscript, poppler-utils, tesseract-ocr, and a Java runtime.
# Clone and configure
git clone <repo-url> pdf-accessibility-app
cd pdf-accessibility-app
cp .env.example .env
# Edit .env and set LLM_API_KEY (a CAIL Gateway key)
# Install dependencies
cd backend && uv sync
cd ../frontend && bun installRun the two services in separate terminals:
# Backend
cd backend
uv run uvicorn app.main:app --reload --port 8001
# Frontend
cd frontend
bun devOpen http://localhost:5173. The frontend proxies /api and /health to the backend on port 8001.
A single-container image bundles all system dependencies and serves the built frontend from FastAPI.
cp .env.example .env
# Set LLM_API_KEY
docker compose up -d --buildOpen http://localhost:8080. Set APP_PORT in .env to use a different port.
To run without Compose:
docker build -t pdf-accessibility-app .
docker run -d \
--name pdf-accessibility-app \
--env-file .env \
-p 8080:8001 \
-v pdf_accessibility_data:/app/data \
-v pdf_accessibility_cache:/home/app/.cache \
pdf-accessibility-appBy default, the Docker image is slim and expects DOCLING_SERVE_URL to point to
a remote docling-serve instance. Set WITH_LOCAL_DOCLING=true before building
to include local Docling and preload models into the image. For subpath
deployments (e.g., behind a reverse proxy), set
VITE_APP_BASE_PATH=/pdf-accessibility/ before building.
Tesseract language packs included: English, Spanish, French, German, Chinese (Simplified and Traditional), Russian, Arabic, Korean, Bengali, Polish, Hebrew, Yiddish, Haitian Creole, Hindi, Italian, Portuguese, and Japanese. Add others by extending the Dockerfile.
Version 2 serves the app at https://tools.ailab.gc.cuny.edu/pdf-accessibility/
for signed-in CUNY people. A Cloudflare Worker (worker/) owns the app, its
API, jobs, and files; the pipeline runs on actual-dell (python -m app.runner)
and takes work from the Worker. Each person's model calls use their own
Gateway budget. See docs/architecture.md,
deploy/actual-dell/README.md, and
worker/README.md.
Version 1 ran on the NML host (lw4av) from /data/pdf-accessibility-app
until 2026-10-28, when v2 replaced it. Its container and volumes are removed
and the secrets in its .env files are blanked. The host's nginx now answers
/pdf-accessibility with a permanent redirect to v2 (debian/alt-text.conf in
whisper-server-nml). The FastAPI app in backend/ still runs for local
development (Quick Start, Docker); the runner reuses its pipeline.
Configure the app via .env. Key variables:
| Variable | Description | Default |
|---|---|---|
LLM_BASE_URL |
Chat-completions base URL (the CAIL Gateway) | https://tools.ailab.gc.cuny.edu/v1 |
LLM_API_KEY |
CAIL Gateway key (required) | — |
LLM_MODEL |
Open-weight vision model on the Gateway | qwen3-vl-235b-a22b-instruct |
LLM_MAX_REQUEST_BYTES |
Maximum complete encoded vision request body; oversized page/crop images are recompressed or downscaled before transport | 2097152 (2 MiB) |
ALT_TEXT_MAX_CONCURRENCY |
Max concurrent alt-text requests per PDF | 8 |
ALT_TEXT_GLOBAL_MAX_CONCURRENCY |
Process-wide cap for alt-text requests | 12 |
MAX_UPLOAD_SIZE_BYTES |
Maximum raw upload size accepted by the app | 104857600 |
MAX_UPLOAD_PAGES |
Maximum pages accepted for self-service processing | 300 |
MAX_UPLOAD_PAGE_RENDER_PIXELS |
Maximum estimated pixels for one page rendered at UPLOAD_PREFLIGHT_RENDER_DPI |
75000000 |
MAX_UPLOAD_IMAGE_PIXELS |
Maximum embedded image pixels on one page | 75000000 |
MAX_UPLOAD_TOTAL_IMAGE_PIXELS |
Maximum embedded image pixels across the PDF | 1000000000 |
MAX_UPLOAD_IMAGE_HEAVY_PAGES |
Maximum pages with at least UPLOAD_IMAGE_HEAVY_PAGE_MIN_PIXELS image pixels |
75 |
MAX_UPLOAD_OCR_SCAN_TOTAL_IMAGE_PIXELS |
Stricter embedded scan-image pixel cap for OCR/Paper Capture PDFs | 150000000 |
MAX_UPLOAD_OCR_SCAN_IMAGE_HEAVY_PAGES |
Stricter image-heavy page cap for OCR/Paper Capture PDFs | 25 |
MAX_FILES_PER_UPLOAD |
Maximum PDFs accepted in one upload request | 5 |
MAX_ACTIVE_JOBS_PER_SESSION |
Maximum queued/processing jobs per anonymous browser session | 3 |
MAX_ACTIVE_JOBS_GLOBAL |
Maximum queued/processing jobs accepted across the app | 12 |
MAX_CONCURRENT_JOBS |
Maximum PDF pipeline jobs actively executing in-process | 2 |
OCRMYPDF_JOBS |
OCRmyPDF worker count per PDF; keep low on shared hosts | 1 |
OCRMYPDF_MAX_IMAGE_MPIXELS |
OCRmyPDF image decompression ceiling per image | 75 |
WITH_LOCAL_DOCLING |
Include local Docling in Docker builds | false |
DOCLING_SERVE_URL |
Remote docling-serve URL (falls back to local Docling when unset) |
— |
DOCLING_SERVE_TOKEN |
Bearer token for a protected docling-serve proxy |
— |
OCR_LANGUAGE |
Fallback Tesseract language code | eng |
JOB_TTL_HOURS |
Hours before jobs expire | 12 |
CSRF_PROTECTION_ENABLED |
Require CSRF header for cookie-authenticated write requests | true |
VERAPDF_PATH |
Path to the veraPDF binary | verapdf |
GHOSTSCRIPT_PATH |
Path to the Ghostscript binary | gs |
Structure extraction is the slowest pipeline step. Running a persistent docling-serve process eliminates cold starts and enables GPU acceleration. Start it with:
DOCLING_DEVICE=mps docling-serve run --host 0.0.0.0 --port 5001Then set DOCLING_SERVE_URL=http://localhost:5001 in .env. Without DOCLING_SERVE_URL, the app falls back to local Docling on CPU.
The app auto-detects document language during classification and selects the matching Tesseract pack for OCR. Digital and mixed PDFs use lingua-py on the extracted text. Scanned PDFs probe OCR on page 1 with every installed language pack, then run lingua-py on the result. If no pack is installed for the detected language, the app falls back to OCR_LANGUAGE.
On macOS, brew install tesseract-lang installs all packs. On Debian/Ubuntu, install individual packs with apt install tesseract-ocr-<lang>.
Each upload runs through seven steps:
- Classify — Determine whether the PDF is digital, mixed, or scanned
- OCR — Add a searchable text layer to scanned pages
- Structure — Extract document structure via Docling, with LLM-assisted TOC enhancement
- Alt Text — Generate alt text for figures and reclassify misidentified elements
- Tag — Resolve ambiguous semantics (tables, forms, reading order) via LLM, then write PDF/UA tags deterministically
- Validate — Check PDF/UA-1 compliance with veraPDF
- Fidelity — Verify output faithfulness (text drift, reading order, table coverage, form labels)
backend/
app/
api/ FastAPI route handlers
pipeline/ Classify, OCR, structure, tag, validate, fidelity
services/ Semantic adjudication, storage, LLM client
models.py SQLAlchemy ORM models
config.py App settings
tests/
frontend/
src/
pages/ Upload, Dashboard, JobDetail, Review
components/
api/ TanStack Query hooks
types/
data/ Runtime storage (git-ignored)
# Backend tests
cd backend
PYTHONPATH=. uv run pytest tests -q
# Frontend lint and build
cd frontend
bun run lint
bun run buildFrom the repository root, run the credential-free source gate (backend Ruff, Pyright and pytest; frontend lint/build; Worker checks):
./scripts/check.shVerify the effective runtime (LLM provider, Docling target, installed binaries) with:
cd backend
PYTHONPATH=. uv run python scripts/runtime_diagnostics.pyWhen the app is running, /health is a lightweight liveness check and
/health/ready verifies runtime dependencies needed for real PDF processing:
database connectivity, writable storage, required PDF binaries, LLM
configuration, and Docling/docling-serve availability.
A remote docling-serve is probed at most once per 30 seconds per app process;
readiness requests inside that window reuse the last result, so repeated calls
to the public endpoint do not reach docling-serve.
- Architecture
- Benchmarks
- Accessibility Coverage
- Accessibility Coverage Matrix
- PDF/UA Rule Coverage
- Backend README
- Frontend README
v2 at tools.ailab.gc.cuny.edu/pdf-accessibility/ is for signed-in CUNY
people: Doorway signs them in and checks Admission membership, and anonymous
use ended at the cutover. Every AI step calls the CAIL Gateway with rendered
page images, never PDF files. In v2 each call goes through the Worker's relay
with the person's own Gateway credential, so it counts against their budget;
the Gateway admits only open-weight models. Run locally, the FastAPI app uses
LLM_API_KEY and anonymous browser sessions. See
docs/cail-integration-plan.md.