Docspell OCR, tested on scanned PDFs
10 of 80
pages got text from an 80-page scan
The official compose file works
The Docspell docs point to one compose file. I ran it as printed, with both ports on 127.0.0.1.
1,881 of 1,955 true words found
49 to 57 s for 10 pages
Docspell 0.43.0 started in 10 seconds. A typed cover page in front kept all 13 of its words. Before you open it to a network, change the secrets its comments name.
The whole compose file that ran
name: docspell
services:
# The restserver and joex containers defined here are configured
# using env variables. Both must connect to the same database and
# solr instance. More information on configuring can be found here:
# https://docspell.org/docs/configure
#
# Please replace the values of the following with a custom secret
# string:
#
# - DOCSPELL_SERVER_ADMIN__ENDPOINT_SECRET
# - DOCSPELL_SERVER_AUTH_SERVER__SECRET
# - DOCSPELL_SERVER_INTEGRATION__ENDPOINT_HTTP__HEADER_HEADER__VALUE
# (use the same value at the consumedir container!)
#
# After creating an account, you may want to set signup mode to
# "closed" or to "invite". When using invite, you must also set
# DOCSPELL_SERVER_BACKEND_SIGNUP_NEW__INVITE__PASSWORD to some
# secret.
restserver:
image: ghcr.io/docspell/restserver:latest
hostname: docspell-restserver
container_name: docspell-restserver
restart: unless-stopped
ports:
- "127.0.0.1:7880:7880"
environment:
TZ: 'Europe/Berlin'
DOCSPELL_SERVER_INTERNAL__URL: 'http://docspell-restserver:7880'
DOCSPELL_SERVER_ADMIN__ENDPOINT_SECRET: 'admin123'
DOCSPELL_SERVER_AUTH_SERVER__SECRET: ''
DOCSPELL_SERVER_BACKEND_JDBC_PASSWORD: 'dbpass'
DOCSPELL_SERVER_BACKEND_JDBC_URL: 'jdbc:postgresql://db:5432/dbname'
DOCSPELL_SERVER_BACKEND_JDBC_USER: 'dbuser'
DOCSPELL_SERVER_BIND_ADDRESS: '0.0.0.0'
DOCSPELL_SERVER_FULL__TEXT__SEARCH_ENABLED: 'true'
DOCSPELL_SERVER_FULL__TEXT__SEARCH_SOLR_URL: 'http://docspell-solr:8983/solr/docspell'
DOCSPELL_SERVER_INTEGRATION__ENDPOINT_ENABLED: 'true'
DOCSPELL_SERVER_INTEGRATION__ENDPOINT_HTTP__HEADER_ENABLED: 'true'
DOCSPELL_SERVER_INTEGRATION__ENDPOINT_HTTP__HEADER_HEADER__VALUE: 'integration-password123'
DOCSPELL_SERVER_BACKEND_SIGNUP_MODE: 'open'
DOCSPELL_SERVER_BACKEND_SIGNUP_NEW__INVITE__PASSWORD: ''
DOCSPELL_SERVER_BACKEND_ADDONS_ENABLED: 'false'
joex:
image: ghcr.io/docspell/joex:latest
hostname: docspell-joex
container_name: docspell-joex
## For more memory add corresponding arguments, like below. Also see
## https://docspell.org/docs/configure/#jvm-options
# command:
# - -J-Xmx3G
restart: unless-stopped
environment:
TZ: 'Europe/Berlin'
DOCSPELL_JOEX_APP__ID: 'joex1'
DOCSPELL_JOEX_PERIODIC__SCHEDULER_NAME: 'joex1'
DOCSPELL_JOEX_SCHEDULER_NAME: 'joex1'
DOCSPELL_JOEX_BASE__URL: 'http://docspell-joex:7878'
DOCSPELL_JOEX_BIND_ADDRESS: '0.0.0.0'
DOCSPELL_JOEX_FULL__TEXT__SEARCH_ENABLED: 'true'
DOCSPELL_JOEX_FULL__TEXT__SEARCH_SOLR_URL: 'http://docspell-solr:8983/solr/docspell'
DOCSPELL_JOEX_JDBC_PASSWORD: 'dbpass'
DOCSPELL_JOEX_JDBC_URL: 'jdbc:postgresql://db:5432/dbname'
DOCSPELL_JOEX_JDBC_USER: 'dbuser'
DOCSPELL_JOEX_ADDONS_EXECUTOR__CONFIG_RUNNER: 'docker,trivial'
DOCSPELL_JOEX_CONVERT_HTML__CONVERTER: 'weasyprint'
ports:
- "127.0.0.1:7878:7878"
## Uncomment when using the "docker" runner with addons
# volumes:
# - /var/run/docker.sock:/var/run/docker.sock
# - /tmp:/tmp
# The consumedir container watches a directory for files to upload
# to docspell restserver. This uses the `dsc` tool. For information
# on the available options, see `dsc --help`.
# https://github.com/docspell/dsc
#
# The value after `Docspell-Integration` must match the secret
# specified at the restserver via
# DOCSPELL_SERVER_INTEGRATION__ENDPOINT_HTTP__HEADER_HEADER__VALUE.
#
# The --not-matches "**/.*" option is to exclude hidden files.
consumedir:
image: docspell/dsc:latest
container_name: docspell-consumedir
command:
- dsc
- "-d"
- "http://docspell-restserver:7880"
- "watch"
- "--delete"
- "-ir"
- "--not-matches"
- "**/.*"
- "--header"
- "Docspell-Integration:integration-password123"
- "/opt/docs"
restart: unless-stopped
volumes:
- ./docs:/opt/docs
db:
image: postgres:18.7
container_name: postgres_db
restart: unless-stopped
volumes:
- postgres_data:/var/lib/postgresql/
environment:
POSTGRES_USER: 'dbuser'
POSTGRES_PASSWORD: 'dbpass'
POSTGRES_DB: 'dbname'
solr:
image: solr:10
container_name: docspell-solr
restart: unless-stopped
volumes:
- solr_data:/var/solr
environment:
SOLR_OPTS: "-Dsolr.modules=analysis-extras"
command:
- bash
- -c
- 'precreate-core docspell; exec solr start -f --user-managed'
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8983/solr/docspell/admin/ping"]
interval: 1m
timeout: 10s
retries: 2
start_period: 30s
volumes:
postgres_data:
solr_data:
Long scans stop at 5 minutes
Docspell gives OCRmyPDF 5 minutes a file, one page at a time. I sent 80 scanned pages, and the job log said:
PDF conversion failed: Timed out after: 300000 ms. Go without PDF file
10 of 80 pages got text
0 pages with a text layer in the PDF
Docspell then ran its own OCR on the first 10 pages only, and stored them out of order. The other 70 pages have no text.
Fix: raise the OCRmyPDF timeout
Save this as docker-compose.override.yml next to the compose file,
then run docker compose up -d. Compose reads it on its
own.
services:
joex:
environment:
DOCSPELL_JOEX_CONVERT_OCRMYPDF_COMMAND_TIMEOUT: "20 minutes"
80 of 80 pages got text
15,048 of 15,640 true words found
OCRmyPDF needed 6 minutes for the 80 pages. Other settings work the
same way: in the config key, dots become _ and dashes
become __.
There is no default login
The compose file sets open signup, so make an account at
/app/register. I tried admin and
admin, and the answer was:
Login failed.
A collective and a user with the same name log in with that one name. I saved this as login.sh:
curl -s -X POST "$DS/api/v1/open/auth/login" -H "Content-Type: application/json" \
-d "{\"account\": \"tack\", \"password\": \"$DS_PASSWORD\"}" | jq -r .token
The token lasts 5 minutes. A script that waits on long OCR jobs has to log in again.
Uploads are read as German
An upload with no language gets the collective's language, German by default. Name it on each upload. I saved this as upload.sh:
META="{\"multiple\": false${2:+, \"language\": \"$2\"}}"
curl -s -H "X-Docspell-Auth: $DS_TOKEN" \
-F "meta=$META;type=application/json" -F "file=@$1" \
"$DS/api/v1/sec/upload/item" | jq -r .message
| Language | 10 English pages | 3 German pages |
|---|---|---|
| deu, the default | 1,865 | 1,845 |
| eng | 1,881 | 1,622 |
With eng, the German pages lost all 206 umlauts (ä, ö, ü, ß). With deu, 192 were right. Docspell sends one language to OCRmyPDF, so a file with two languages gets one of them.
On a Mac, skip the arm64 image
The official images are amd64 only. On Apple silicon, Docker prints a platform warning and runs them under emulation. Every number above ran that way.
Docker Hub has arm64 images up to v0.42.0, from August 2024. There, OCRmyPDF stopped after 1 second:
Tesseract failed to report available languages.
Docspell's own OCR then read the pages out of order, and the PDF got no text layer.
Get the text with two calls
Docspell keeps the OCR text with each attachment. I saved this as text.sh:
H="X-Docspell-Auth: $DS_TOKEN"
ATT=$(curl -s -H "$H" "$DS/api/v1/sec/item/$1" | jq -r '.attachments[0].id')
curl -s -H "$H" "$DS/api/v1/sec/attachment/$ATT/meta" | jq -r .content
Search works too: content:Hatton found the 4 uploads that
hold the scan.
Docspell vs Paperless-ngx, same files
I ran Paperless-ngx and Papermerge on the same scans earlier, each with its own defaults:
| App | 10-page scan | Cover + scan |
|---|---|---|
| Docspell 0.43.0 | 1,881 | 1,881 |
| Paperless-ngx 3.4.2 | 1,874 | 2 |
| Papermerge 3.5.3 | 1,864 | 1,864 |
Paperless-ngx skips OCR when a page already has text. Docspell runs
OCRmyPDF with --skip-text, so only the cover is skipped.
Want Markdown out? Use the Actor
Docspell is free and files your papers. Our OCR Actor gives Markdown with no server. On the same files:
1,874 of 1,955 English words
192 of 206 German umlauts
It also kept the 13 cover words. It costs $0.006 a page.
How I tested it
- Scan: the 10-page PDF of OCR in n8n, proofread Wikisource book scans with no text layer. 1,955 true words.
- Long scan: that PDF 8 times in one file, 80 pages, 15,640 true words. With no fix, its stored text was scored page by page, order ignored.
- Cover page: a 13-word typed sheet in front of the scan, the file of AnythingLLM OCR.
- German: 3 proofread de.wikisource pages, the file of Paperless-ngx OCR. 1,887 words, 206 umlauts.
- Score: true words found in order, with the scorer of the tessdata test.
- Docspell 0.43.0 (Tesseract 5.5.0, OCRmyPDF 16.13.0), from the official compose file, with Postgres 18.7 and Solr 10. v0.42.0: the Docker Hub arm64 images.
- Mac with Apple silicon, Docker 29.8.2. Joex on 2 CPUs. Times are from the job start to its end.
- The code blocks above are the files that ran. Paperless-ngx, Papermerge and Actor rows: my earlier runs on the same files.
What I did not test
- Email import, the watch folder, and addons.
- The web UI, tags and the metadata guesses.
- Office files, images, and handwriting.
Sources
Two ways to skip the work
Run it yourself. Our Apify Actor turns scanned PDFs into Markdown, tables included.
Or send us the scan. Tack, our AI, does the job for $29, up to 50 pages. First page free.
Questions people ask
Why does Docspell OCR only the first 10 pages?
OCRmyPDF hit Docspell's 5-minute limit, so Docspell ran its own OCR on the first 10 pages only. Raise DOCSPELL_JOEX_CONVERT_OCRMYPDF_COMMAND_TIMEOUT: with 20 minutes, every page of the test scan got text.
What is the Docspell default login?
There is none. The official compose file sets open signup, so you make an account at /app/register. admin and admin got Login failed.
Why does Docspell read my English scans as German?
An upload with no language gets the collective's language, German by default. Name the language on each upload, as upload.sh on this page does.