A patched docling-serve Docker image that bundles two community plugins:
| Image | Based on | Architectures | Notes |
|---|---|---|---|
ghcr.io/dcc-bs/dcc-docling-serve |
docling-serve |
linux/amd64 | Base image, packages from PyPI (torch with CUDA 13) |
ghcr.io/dcc-bs/dcc-docling-serve-cpu |
docling-serve-cpu |
linux/amd64, linux/arm64 | CPU-only, torch from PyTorch CPU index |
ghcr.io/dcc-bs/dcc-docling-serve-cu128 |
docling-serve-cu128 |
linux/amd64 | CUDA 12.8, torch from cu128 index |
ghcr.io/dcc-bs/dcc-docling-serve-cu130 |
docling-serve-cu130 |
linux/amd64 | CUDA 13.0, torch from cu130 index |
Each image is tagged with the docling-serve version it is built on (e.g. v1.37.0) and :latest:
dcc-docling-serve:v1.37.0 is docling-serve v1.37.0 with our plugins and patches.
| Plugin | PyPI | Purpose |
|---|---|---|
| docling-glm-ocr | pip install docling-glm-ocr |
Remote OCR via a vLLM-hosted GLM-OCR model |
| docling-pp-doc-layout | pip install docling-pp-doc-layout |
Local layout detection via PP-DocLayout-V3 |
| docling-pp-ocrv6 | pip install docling-pp-ocrv6 |
Local OCR via PP-OCRv6 (medium), with a box per word |
The plugins are selectable per-request through the standard docling-serve API:
- Layout --
layout_preset: "ppdoclayout-v3"(preset defined incompose.yaml) orlayout_custom_config: { "kind": "ppdoclayout-v3" } - OCR --
ocr_preset: "glm-ocr-remote"orocr_preset: "pp-ocrv6"
The image pins the plugin versions (DOCLING_GLM_OCR_VERSION,
DOCLING_PP_DOC_LAYOUT_VERSION and DOCLING_PP_OCRV6_VERSION in
plugins/Dockerfile.docling-serve) and bakes the PP-DocLayout-V3 and PP-OCRv6 models
into it at pinned Hugging Face revisions (PP_DOC_LAYOUT_MODEL_REVISION,
PP_OCRV6_DET_REVISION, PP_OCRV6_REC_REVISION), so no pod downloads them and a new
upload to a model repo changes nothing until the revision here is moved. Renovate opens a
PR when a new plugin version is released; merge it, then run the image build again for the current version.
Unpinned, the build's layer cache would keep shipping the old plugins.
The docling-serve web UI at /ui is upstream's, unchanged. It lists both plugins
among its OCR and layout choices.
flowchart TD
subgraph doclingServe ["dcc-docling-serve"]
LP["PP-DocLayout-V3 plugin"]
OP["GLM-OCR plugin"]
end
subgraph vllmServer [vLLM Server]
GLMOCR["zai-org/GLM-OCR"]
end
OP -- "remote OCR" --> GLMOCR
- Docker with GPU support (NVIDIA)
- A HuggingFace token with access to the GLM-OCR model
Copy .env.example to .env and set the required variables:
cp .env.example .env
# edit .env — at minimum set HF_TOKEN| Variable | Description | Default |
|---|---|---|
HF_TOKEN |
HuggingFace token for downloading GLM-OCR (required) | — |
HF_CACHE_DIR |
Host directory for the HF model cache | .hf-cache |
VLLM_HOST_PORT |
Host port for the vLLM server | 8001 |
DOCLING_HOST_PORT |
Host port for docling-serve | 5001 |
DOCLING_SERVE_TAG |
Upstream docling-serve image tag | latest |
DOCLING_SERVE_LOG_LEVEL |
Log level for docling-serve | INFO |
make docker-upThis starts two services:
| Service | Purpose |
|---|---|
| vllm-glm-ocr | vLLM server hosting zai-org/GLM-OCR (GPU 1) |
| docling-serve | Docling API + web UI with both plugins (GPU 0) |
The web UI is available at http://localhost:5001/ui.
Remote GLM-OCR OCR with PP-DocLayout-V3 layout:
curl -X POST http://localhost:5001/v1/convert/source \
-H 'Content-Type: application/json' \
-d '{
"options": {
"ocr_engine": "glm-ocr-remote",
"layout_custom_config": { "kind": "ppdoclayout-v3" }
},
"sources": [{"kind": "http", "url": "https://arxiv.org/pdf/2501.17887"}]
}'The compose.yaml references the pre-built image from GHCR:
ghcr.io/dcc-bs/dcc-docling-serve:latest
| Service | Image |
|---|---|
| vllm-glm-ocr | vllm/vllm-openai:v0.30.0 |
| docling-serve | ghcr.io/dcc-bs/dcc-docling-serve:latest |
Environment variables are documented in .env.example.
make docker-up # start all services
make docker-down # stop all servicesTo build the patched docling-serve image from source:
make docker-buildThis runs docker build against plugins/Dockerfile.docling-serve (default upstream
image, latest tag) and tags the result as $DOCLING_IMAGE
(default ghcr.io/dcc-bs/dcc-docling-serve:latest).
To test the local image without pushing it to GHCR, run docling-serve directly
with docker run:
docker run --add-host=host.docker.internal:host-gateway --rm \
--gpus device=0 \
-p 5001:5001 \
-e DOCLING_SERVE_ENABLE_UI=true \
-e DOCLING_SERVE_ENABLE_REMOTE_SERVICES=true \
-e DOCLING_SERVE_ALLOW_EXTERNAL_PLUGINS=true \
-e GLMOCR_REMOTE_OCR_API_URL=http://host.docker.internal:8001/v1/chat/completions \
ghcr.io/dcc-bs/dcc-docling-serve:latestThe web UI is then available at http://localhost:5001/ui.
Because make docker-build tags the image with the name compose.yaml uses, the
normal compose flow picks up the local build:
make docker-build
make docker-upAll plugins are fully configurable via environment variables, making them
suitable for zero-code deployment in Docker / Compose environments.
Explicit GlmOcrRemoteOptions and PPDocLayoutV3Options constructor arguments
always take precedence when using the Python SDK directly.
| Variable | Description | Default |
|---|---|---|
GLMOCR_REMOTE_OCR_API_URL |
vLLM chat completion URL | http://localhost:8001/v1/chat/completions |
GLMOCR_REMOTE_OCR_MODEL_NAME |
Model name sent to vLLM | zai-org/GLM-OCR |
GLMOCR_REMOTE_OCR_PROMPT |
Text prompt sent with each image crop | built-in default |
GLMOCR_REMOTE_OCR_TIMEOUT |
HTTP timeout per crop (seconds) | 120 |
GLMOCR_REMOTE_OCR_MAX_TOKENS |
Max tokens per completion | 16384 |
GLMOCR_REMOTE_OCR_SCALE |
Image crop rendering scale | 3.0 |
GLMOCR_REMOTE_OCR_MAX_IMAGE_PIXELS |
Pixel budget per crop | 4500000 |
GLMOCR_REMOTE_OCR_MAX_CONCURRENT_REQUESTS |
Max concurrent API requests | 10 |
GLMOCR_REMOTE_OCR_MAX_RETRIES |
Max retry attempts for HTTP errors | 3 |
GLMOCR_REMOTE_OCR_RETRY_BACKOFF_FACTOR |
Exponential backoff factor for retries | 2.0 |
GLMOCR_REMOTE_OCR_LANG |
Comma-separated language hint(s) | en |
GLMOCR_REMOTE_OCR_API_KEY |
Bearer token for Authorization header |
unset (no header sent) |
| Variable | Description | Default |
|---|---|---|
PP_DOC_LAYOUT_MODEL_NAME |
HuggingFace model repo ID or local folder | our image: the baked-in copy (see below); plugin: PaddlePaddle/PP-DocLayoutV3_safetensors |
PP_DOC_LAYOUT_CONFIDENCE_THRESHOLD |
Minimum detection confidence (0.0–1.0) | 0.3 |
PP_DOC_LAYOUT_BATCH_SIZE |
Batch size for layout inference | 8 |
PP_DOC_LAYOUT_CREATE_ORPHAN_CLUSTERS |
Create clusters for orphaned elements (true/false) |
true |
PP_DOC_LAYOUT_KEEP_EMPTY_CLUSTERS |
Retain empty clusters in results (true/false) |
false |
PP_DOC_LAYOUT_SKIP_CELL_ASSIGNMENT |
Skip table-cell assignment (true/false) |
false |
PP_DOC_LAYOUT_LIST_DETECTION |
List-item detection: rules, heron or off (see Lists with PP-DocLayout-V3) |
rules |
Boolean variables accept true, 1, yes (case-insensitive) as truthy; anything else is false.
Our image contains the PP-DocLayout-V3 weights (128 MB, pinned by PP_DOC_LAYOUT_MODEL_REVISION in
plugins/Dockerfile.docling-serve) and points PP_DOC_LAYOUT_MODEL_NAME at them. Without them the plugin
fetched the model from huggingface.co every time a pipeline was built, and downloaded it on every
container start; now the worker runs without internet access. To update the weights, set the new
commit of PaddlePaddle/PP-DocLayoutV3_safetensors as PP_DOC_LAYOUT_MODEL_REVISION.
The threshold defaults to 0.3 since plugin 0.2.5 (it was 0.5). docling only OCRs inside
layout regions, and PP-DocLayout-V3 (trained on document pages) detects handwriting on phone
photos with only 0.3–0.4 confidence. At 0.5 most of the handwritten list on the test photo got
no region and was never read. At 0.3 over all 28 test documents: photo handwriting 11 % → 44 % with
RapidOCR and 22 % → 89 % with GLM-OCR, headings on the synthetic documents 0.85 → 0.96, born-digital
text unchanged. The catch: GLM-OCR also gets low-confidence regions on text-free photos and can
invent text there (on one photo page it repeated "Wiesbaden" 483 times); RapidOCR does not.
GLM-OCR 0.5.2 discards such repetition loops, but a short invented line can still get through. See
the engine dashboard for the full comparison.
All environment variables above correspond to fields on GlmOcrRemoteOptions and PPDocLayoutV3Options. See the individual plugin READMEs for the full option reference:
The upstream CUDA images ship the CPU-only onnxruntime, so RapidOCR (the default
OCR engine, onnxruntime backend) runs on the CPU even on a GPU and dominates the
processing time. plugins/install_onnxruntime_gpu.sh fixes this at build time for
every image whose torch has CUDA support. CPU images are left unchanged.
- It replaces
onnxruntimewith the matchingonnxruntime-gpu: PyPI for CUDA 13 (same version as upstream), the onnxruntime CUDA 12 feed for CUDA 12. - It registers the CUDA/cuDNN libraries of the
nvidia-*wheels withldconfig, so the CUDA provider loads regardless of whether torch was imported first. - The build fails if
CUDAExecutionProvideris missing or its libraries do not resolve.
No request option is needed. docling already enables CUDA for RapidOCR when the accelerator device is CUDA, which it is by default when a GPU is visible.
Kubernetes/Compose liveness probes hitting GET /health produce two INFO records
per probe (docling_serve.app "Health check requested" and the matching
uvicorn.access line). The image ships plugins/mute_health_logs.py, installed
into site-packages as dcc_mute_health_logs.py plus a .pth file that imports
it at interpreter startup. It attaches logging.Filters to those two loggers —
no upstream docling-serve file is modified, so it survives upstream updates. The
filters sit on the Logger objects, not on handlers, so docling-serve's own
setup_logging() (which only swaps handlers/levels) does not undo them.
| Variable | Description | Default |
|---|---|---|
DCC_MUTE_HEALTH_LOGS |
Set to 0/false/no to disable muting |
1 |
DCC_MUTE_HEALTH_PATHS |
Comma-separated request paths to mute in the access log | /health |
DoclingDocument carries regions and their text, not words. Whoever needs to
know where a word stands — to highlight a hit, to anchor a citation, to mark a
name for redaction — has to parse the file a second time on their own side, and
run their own OCR for the pages that have no text layer. docling has measured
all of it already and throws it away after assembly.
The image ships plugins/word_boxes.py, installed into site-packages as
dcc_word_boxes.py plus a .pth file, like the health-log muting. It adds one
request option and one field to the answer:
include_word_boxes |
request option, boolean, defaults to false |
word_boxes |
answer field, beside md_content and the other contents |
word_boxes holds, per page number, docling's own SegmentedPdfPage:
word_cells and textline_cells, each cell with its text, the rectangle it was
read in (four corners, so text at an angle keeps its angle) and from_ocr.
char_cells and the page picture are dropped — they are large, and a word's
text and box is what was asked for.
curl -s -X POST localhost:5001/v1/convert/file \
-F files=@document.pdf -F to_formats=json -F pdf_backend=dlparse_v4 \
-F include_word_boxes=true |
python3 -c "import sys, json; pages = json.load(sys.stdin)['document']['word_boxes']; \
print({n: len(p['word_cells']) for n, p in pages.items()})"Only a docling-parse backend measures a box per word: with pdf_backend=pypdfium2
the answer carries the words OCR read and none of the ones the text layer draws.
What it changes. The option sets generate_parsed_pages on the pipeline, so
the cells survive assembly; ExportableDocument carries them off the conversion
result and the export writes them into the answer. Nothing else moves: the option
defaults to false, no existing field changes, and a request that does not ask
for it is answered exactly as before — the field is then null, like the other
contents that were not requested.
With PP-OCRv6 (ocr_preset=pp-ocrv6) the option also turns on the engine's
whole_page and return_word_box: every page is read whole, so a line is not cut
at a layout box, and each word OCR reads gets a box of its own. A word the text
layer draws is not doubled; over invisible text (a scanner's layer) the read words
stay. Both options cost time and
stay off for requests that do not ask for word boxes. docling-serve keeps one
converter per set of options, so the two kinds of request do not share one; with
more than two kinds in use, raise DOCLING_SERVE_OPTIONS_CACHE_SIZE (default 2) to
avoid reloading models.
Adding a field to a pydantic model is not enough on its own: a model that holds another one keeps a copy of its schema from when it was first built, so the models of those two modules are rebuilt as well. Without that the field validates but is dropped again on the way out.
| Variable | Description | Default |
|---|---|---|
DCC_WORD_BOXES |
Set to 0/false/no to leave the API as upstream has it |
1 |
If a wrapped docling function is missing (renamed upstream), the patch logs a warning and does nothing; conversions keep working, without the field.
The plugins can also be used directly with the docling Python SDK (without docling-serve). See examples/convert_with_plugins.py:
from docling.datamodel.base_models import InputFormat
from docling.datamodel.pipeline_options import PdfPipelineOptions
from docling.document_converter import DocumentConverter, PdfFormatOption
from docling_glm_ocr import GlmOcrRemoteOptions
from docling_pp_doc_layout.options import PPDocLayoutV3Options
pipeline_options = PdfPipelineOptions(
allow_external_plugins=True,
ocr_options=GlmOcrRemoteOptions(api_url="..."),
layout_options=PPDocLayoutV3Options(),
)
converter = DocumentConverter(format_options={InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_options)})
result = converter.convert("https://arxiv.org/pdf/2501.17887")
print(result.document.export_to_markdown())Standalone command to run the GLM-OCR vLLM server:
docker run -d \
--rm --name ocr-glm \
--gpus device=1 \
--ipc=host \
-p 8001:8000 \
-v "${HOME}/.cache/huggingface:/root/.cache/huggingface" \
-e "HF_TOKEN=${HF_TOKEN:-}" \
-e "LD_LIBRARY_PATH=/lib/x86_64-linux-gnu" \
ghcr.io/dcc-bs/vllm:v0.16.0-cu130 \
zai-org/GLM-OCR \
--served-model-name zai-org/GLM-OCR \
--port 8000 \
--trust-remote-code \
--max-num-batched-tokens 8192Without this flag, vLLM will reject any high-resolution image with HTTP 400.
In vLLM 0.16.0+ (v1 engine), the encoder cache size is derived from
max_num_batched_tokens (default 2048 when chunked prefill is enabled):
encoder_cache_size = max(max_num_batched_tokens, model_max_tokens_per_image)
= max(2048, 4800) ← 4800 is GLM-OCR's model floor
= 4800 tokens ← too small for real documents
The Glm46VImageProcessor encodes images at approximately 784 pixels per token
(patch_size=14 × merge_size=2, squared). A typical A4 page rendered at scale 3×
(1785 × 2526 px) produces 5760 tokens; a phone-photo crop at scale 3× can reach
6120 tokens — both exceed the default 4800-token cache and are rejected.
Setting --max-num-batched-tokens 8192 raises the encoder cache to
max(8192, 4800) = 8192 tokens, which covers all real-world inputs with comfortable
headroom.
Note:
--limit-mm-per-promptdoes not control the encoder cache size in vLLM 0.16.0. That flag only limits the count of images per request.
make installmake checkRuns ruff format (auto-format) and ruff check --fix (auto-fix lint errors) locally.
The CI workflow runs these as read-only checks.
make testTests the plugins directly via the Python SDK, without a running docling-serve instance. Requires the plugin repos checked out as siblings of this repo:
./smoke_test.sh
# or with a custom vLLM URL:
GLMOCR_REMOTE_OCR_API_URL=http://host:8001/v1/chat/completions ./smoke_test.shThe e2e tests require a running stack (docling-serve + vLLM). They use the
images in data/ to validate conversion through both plugins.
export DOCLING_SERVE_URL=http://localhost:5001
make testTests are skipped automatically when DOCLING_SERVE_URL is not set.
benchmarks/e2e_timing.py compares end-to-end conversion time of the official and
our docling-serve images (CU130 and CPU) and plain docling installed from PyPI. The
variants run one after another. Each one gets a cold conversion to load its models,
then converts every document --repeats times with the options in OPTIONS.
Results (raw JSONL plus summary.md with per-stage timings) go to benchmarks/results/.
uv run --script benchmarks/e2e_timing.py --docs ../test-docs
uv run --script benchmarks/e2e_timing.py --docs ../test-docs --variants official-cu130 custom-cu130 --repeats 1benchmarks/tool_comparison/ compares docling (upstream, with GPU OCR, and with GPU
OCR plus the former shape patch, which docling 2.136 made obsolete) with marker, docTR, markitdown and Jina Reader on the test
documents plus four synthetic PDFs with exact ground truth (charts, tables and forms,
a scanned page, a designed page). It measures conversion time per document and page,
scores the markdown against the ground truth and the PDF text layer, and builds an
HTML dashboard with side-by-side diffs next to the original page.
benchmarks/tool_comparison/run_all.sh # needs Docker, a GPU and jina_key in ../.env
python3 -m http.server -d benchmarks/tool_comparison/results/dashboard 8765Note that the Jina runner uploads every document to r.jina.ai.
build_dashboard.py reads what to compare from a JSON config: tools and labels, results
folder, documents, assessment file and page texts. With no --config it uses
converters.config.json, which builds the dashboard above.
engines.config.json builds a second dashboard with the same page: RapidOCR and GLM-OCR,
each with docling's default layout model and with PP-DocLayout-V3, all in the published
dcc-docling-serve-cu130:v1.35.0 image (docling-glm-ocr 0.5.2, docling-pp-doc-layout 0.2.5)
with otherwise identical options. It also compares these runs with the earlier ones on
docling-glm-ocr 0.5.1 and docling-pp-doc-layout 0.2.4. On top of the converter views it shows:
- an engine × layout grid for each key metric (ground-truth F1, scanned text, text in images, tables, headings, text-layer recall and precision, reference recall, time)
- a "Previous vs published" tab: pages with text that came out empty, and the documents
where the two plugin versions differ (tab title and column names come from
pairs_taband each pair'stitleandnames) - a "Words not in reference" tab: output words that are missing from the ground truth or the reference transcription, split into misreadings and words with no close match (useful for GLM-OCR, which is a VLM)
The handwritten note (data/ocr.png) and the phone photo in the test documents have no
text layer. They are scored against the transcriptions in
benchmarks/tool_comparison/references.json. Put the pros and cons into
engines.assessment.json (same format as assessment.json).
Results with the published image, all 28 documents (RTX 4090, vLLM 0.29 for GLM-OCR):
| Configuration | Time | Synthetic F1 | Headings | Text-layer recall / precision | Handwritten note | Photo handwriting |
|---|---|---|---|---|---|---|
| RapidOCR + default layout | 167 s | 0.96 | 1.00 | 0.75 / 0.92 | 31 % | 30 % |
| RapidOCR + PP-DocLayout-V3 | 199 s | 0.96 | 0.96 | 0.83 / 0.92 | 27 % | 44 % |
| GLM-OCR + default layout | 190 s | 0.94 | 0.92 | 0.75 / 0.85 | 97 % | 85 % |
| GLM-OCR + PP-DocLayout-V3 | 273 s | 0.94 | 0.96 | 0.84 / 0.90 | 96 % | 89 % |
With docling-glm-ocr 0.5.1 the two GLM-OCR runs took 2'745 s and 2'701 s: the plugin OCR'd every born-digital page as a full page. The text-free photos in the Legislaturplan now come out empty, except for one invented English description of the city photo with PP-DocLayout-V3.
Each configuration is a run_docling.py run whose --tool matches an id in the config
(release-rapidocr, release-rapidocr-pp, release-glm, release-glm-pp, and the
earlier fixed-rapidocr-pp-t03, fixed-glm, fixed-glm-pp-t03), with --results
pointing at one shared folder. Then build and serve:
cd benchmarks/tool_comparison
uv run --script build_dashboard.py --config engines.config.json --results <engine results>
python3 -m http.server -d <engine results>/dashboard 8766Configurations or documents without results are left out; grid cells show "not run".
PP-DocLayout-V3 has no list-item class, so before plugin 0.2.6 docling produced no lists
with it: bullet points came out as paragraphs starting with "·" (367 list items against
Heron's 2'422 on the test documents). The plugin now detects list items after OCR from list
markers, enumerator sequences and bullet marks OCR did not read
(PP_DOC_LAYOUT_LIST_DETECTION=rules, the default), optionally with docling's Heron model
as a second opinion (heron). Details in the plugin README.
benchmarks/list_eval/ holds the benchmark: make_lists.py generates list pages with exact
ground truth (born-digital and scanned, with traps such as numbered headings, dates and
amounts), run_all.sh <image> converts them and the test documents with Heron and with
PP-DocLayout-V3 in each mode, evaluate.py scores them. Results with plugin 0.2.6:
| Configuration | Born-digital R / P | Scanned R / P | Traps as list items | Bullet lines found | Time |
|---|---|---|---|---|---|
| Heron (docling default) | 0.67 / 0.96 | 0.97 / 0.95 | 2 | 245 / 251 | 136 s |
PP-DocLayout-V3, off |
0 | 0 | 0 | 0 / 251 | 145 s |
PP-DocLayout-V3, rules |
1.00 / 1.00 | 0.86 / 1.00 | 0 | 231 / 251 | 145 s |
PP-DocLayout-V3, heron |
1.00 / 1.00 | 0.94 / 0.94 | 1 | 231 / 251 | 153 s |
13 of the 20 bullet lines PP-DocLayout-V3 misses are the brochure's commission list, which it lays out as a picture (the text is in the JSON output, inside the picture). List detection runs inside docling's layout post-processing, which has no plugin interface yet; add a check to the upgrade list below that lists still appear.
Our image changes upstream in a few places (plugins, GPU OCR, the words
of each page). Check each one before publishing an image built on a new docling-serve
version (replace v1.37.0 with the new tag). Build the image first (step 2) for the checks that use it.
-
Plugins in the web UI. We ship upstream's UI unchanged. Check that the plugins still show up where the UI reads its choices from:
glm-ocr-remoteunder the OCR presets andppdoclayout-v3under the layout presets (added byDOCLING_SERVE_CUSTOM_LAYOUT_PRESETSincompose.yaml):docker run -d --name upgrade-check -p 5099:5001 -e DOCLING_SERVE_ENABLE_UI=true \ -e DOCLING_SERVE_ALLOW_EXTERNAL_PLUGINS=true \ -e 'DOCLING_SERVE_CUSTOM_LAYOUT_PRESETS={"ppdoclayout-v3": {"kind": "ppdoclayout-v3"}}' \ dcc-docling-serve-test:cu130 curl -s localhost:5099/v1/capabilities | python3 -c "import sys, json; s = json.load(sys.stdin)['stages']; \ print([p['id'] for p in s['ocr']['presets']], [p['id'] for p in s['layout']['presets']])" docker rm -f upgrade-check
-
Build the images locally (CU130 and CPU at least). The build itself fails if onnxruntime-gpu has no CUDA provider or its libraries do not resolve:
docker build --build-arg DOCLING_SERVE_IMAGE=ghcr.io/docling-project/docling-serve-cu130 \ --build-arg DOCLING_SERVE_TAG=v1.37.0 -t dcc-docling-serve-test:cu130 \ -f plugins/Dockerfile.docling-serve plugins/
-
GPU OCR at runtime. The RapidOCR session must list
CUDAExecutionProviderfirst:docker run --rm --gpus device=0 --entrypoint python dcc-docling-serve-test:cu130 -c " import glob, onnxruntime as ort model = sorted(glob.glob('/opt/app-root/src/.cache/docling/models/RapidOcr/*det*.onnx'))[0] print(ort.InferenceSession(model, providers=['CUDAExecutionProvider', 'CPUExecutionProvider']).get_providers())"
-
Vector shapes do not force OCR. docling 2.136 fixed this upstream (we carried a patch for it until docling-serve 1.37.0), so a rule or a table fill crossing programmatic text no longer sends that text to OCR. Check the fix is still in (
True); if it is gone, restore the patch from git history (plugins/ocr_ignore_shapes.py, removed with the move to 1.37.0):docker run --rm --entrypoint python dcc-docling-serve-test:cu130 -c \ "import docling.models.base_ocr_model as m; print(hasattr(m.BaseOcrModel, '_is_rule_like'))" -
Words of each page. Check that the option and the field are still there, and that they survive nesting (a stale holder schema drops the field on the way out):
docker run --rm --entrypoint python dcc-docling-serve-test:cu130 -c " from docling.datamodel.service.responses import ConvertDocumentResponse, ExportDocumentResponse from docling.datamodel.service.options import ConvertDocumentsOptions print('option:', 'include_word_boxes' in ConvertDocumentsOptions.model_fields) r = ConvertDocumentResponse(document=ExportDocumentResponse(filename='x'), status='success', processing_time=0.1) r.document.word_boxes = {1: None} print('answered:', 'word_boxes' in r.model_dump()['document'])"
-
Speed kept. Compare timings against the previous release; born-digital documents (the CELEX regulation) should not be OCR-bound:
uv run --script benchmarks/e2e_timing.py --docs ../test-docs --tag v1.37.0 --variants official-cu130 custom-cu130 \ --image custom-cu130=dcc-docling-serve-test:cu130
-
Plugins. Run the e2e tests against the stack (GLM-OCR via vLLM, PP-DocLayout-V3) and convert a document in the web UI with both plugins selected. Then check that PP-DocLayout-V3 still produces lists: its list detection hooks into docling's layout post-processing and switches itself off (warning "list detection is off" or "list detection failed" in the logs) if docling changed it.
benchmarks/list_eval/run_all.sh dcc-docling-serve-test:cu130should show thepp-rulesnumbers from the table above. -
Publish. Merge to
main, then run the Build docling-serve with layout and OCR plugins workflow withversion=v1.37.0:gh workflow run cd.yml -f version=v1.37.0
Runs on push/PR: lint with ruff.
Builds and pushes the patched docling-serve images (default, -cpu, -cu128, -cu130)
to GHCR. It runs only when dispatched manually (Actions → Build docling-serve with
layout and OCR plugins → Run workflow, or gh workflow run cd.yml -f version=v1.37.0);
pushing to main never publishes an image. Its one input, version, is a docling-serve
release tag: every variant is built on that upstream tag and published under the same tag
and :latest. Moving tags such as main or latest are refused.
To ship our own changes (a plugin bump, a patch) without a new upstream release, run it again with the current version; it overwrites that tag with the new build.
The tag is resolved to its digest at the start of the run and the build uses that digest, so all variants of one run share the same base. The digest is shown in the run summary and stored in the image labels:
docker inspect ghcr.io/dcc-bs/dcc-docling-serve:v1.37.0 \
--format '{{ index .Config.Labels "org.opencontainers.image.base.name" }} {{ index .Config.Labels "org.opencontainers.image.base.digest" }}'MIT