A small Next.js app: upload a PDF and pick a target language on the client; the server rebuilds each page as a background image with the original text erased and the DeepL translation overlaid in its place, then streams the resulting PDF back for download — preserving the source document's layout, images, tables, and colors.
- Client (
src/app/page.tsx) — a form to pick a PDF file and a target language (DeepL language list insrc/lib/languages.ts), POSTs both to/api/translateasmultipart/form-data, then turns the response into a downloadable blob link. - Server (
src/app/api/translate/route.ts, Node runtime):src/lib/pdfOverlay.ts— renders each page to a bitmap withpdfjs-dist+@napi-rs/canvas, extracts text with exact positions, groups it into visual lines/cells, samples each line's background/text color, paints over the original glyphs directly on the bitmap, and emits an HTML page per PDF page (background image + absolutely-positioned overlay<div>s, one per text line, tagged withdata-i).src/lib/deepl.ts— sends the unique, letter-containing lines to the DeepL API (POST https://api.deepl.com/v2/translate,Authorization: DeepL-Auth-Key <key>) in batches of 50.src/lib/applyTranslations.ts— usescheerioto swap each[data-i="N"]element's text for its translation.src/lib/htmlToPdf.ts— shrinks/wraps any overlay text that doesn't fit its box, then renders the combined HTML to a PDF with Puppeteer (headless Chrome) at the original page size and returns the buffer asapplication/pdf.
This is inherently approximate: translated text is often longer or shorter than the source it replaces, so very tight labels can shrink or wrap rather than fit perfectly. Full prose text generally comes out clean.
npm install
cp .env.local.example .env.localEdit .env.local and set your DeepL key:
DEEPL_API_KEY=your-deepl-api-key
# Free-tier keys (ending in ":fx") must instead use:
# DEEPL_API_URL=https://api-free.deepl.com/v2/translate
Puppeteer downloads its own Chromium build on npm install. If that step fails or gets
interrupted, run:
npx puppeteer browsers install chromenpm run devOpen http://localhost:3000, upload a PDF, pick a target language, and click "Translate PDF".
npm run build
npm startThe image bundles Chromium's system dependencies and fonts covering Latin/Cyrillic/CJK scripts.
cp .env.local.example .env # docker-compose reads variable substitutions from .env
# edit .env and set DEEPL_API_KEY (and DEEPL_API_URL for a free-tier key)
docker compose up -d --buildThe app is then available at http://localhost:3000. docker-compose.yml
sets shm_size: 1gb, since headless Chromium can crash under Docker's default 64MB /dev/shm.
Without compose:
docker build -t pdf-translator .
docker run -d -p 3000:3000 --shm-size=1gb --env-file .env pdf-translator