<!--
  canonical-url: https://www.avonture.be/blog/docling
  published:     2026-09-17
  This is a static plain-Markdown mirror generated at build time.
  Visit the canonical URL above for the fully rendered page, with images and interactive components.
-->

# Docling - Convert PDF, Word, PowerPoint, Excel and HTML to Markdown, GPU-Accelerated

> A Docker batteries-included setup for Docling, IBM's document-conversion library: PDF, Word, PowerPoint, Excel and HTML files converted into clean Markdown, with GPU acceleration for machines with spare VRAM. A companion to my Markitdown article — same idea, a heavier engine underneath.

---
![Docling - Convert PDF, Word, PowerPoint, Excel and HTML to Markdown, GPU-Accelerated](/img/v2/docling.webp)

**TL;DR**

This article does for [Docling](https://github.com/docling-project/docling) exactly what [my Markitdown article](/blog/markitdown) did for Markitdown: a Docker batteries-included image and a `docling-convert` shell function for zsh or bash, so converting a document to clean Markdown is one command from any folder. **The difference is what's under the hood — Docling uses dedicated layout, table-structure and OCR models instead of format-specific parsers, runs on GPU if you have one, and is built from the ground up for exactly the "sensitive document, must stay local" scenario this blog keeps coming back to.**

A friend who was reading [my Markitdown article](/blog/markitdown) suggested I try [Docling](https://docling.ai/) instead — Microsoft-adjacent open-source project, IBM this time, same "convert office documents to Markdown" pitch, but built around actual layout-understanding models rather than per-format parsers. I have 24GB of VRAM sitting mostly idle on my AI server; if a tool can put it to work turning a badly-scanned PDF into something readable, that's worth ten minutes of testing.

## Converting Five Formats

Once the image and the `docling-convert` function are in place (we'll cover this below), converting a document is one command:

```bash
$ cd ~/documents/

$ docling-convert report.pdf
docling-convert: report.md written in 14s
💡 Not happy with the result? Run it again with --pipeline standard (faster, rougher on titles), --image-export-mode placeholder|embedded (drop the images, or inline them), -v (the full log).
💡 Hide this hint with DOCLING_CONVERT_NO_TIP=1.

$ docling-convert contract.docx
docling-convert: contract.md written in 4s

$ docling-convert slides.pptx
docling-convert: slides.md written in 5s

$ docling-convert budget.xlsx
docling-convert: budget.md written in 4s

$ docling-convert page.html
docling-convert: page.md written in 4s

$ ls *.md

budget.md  contract.md  page.md  report.md  slides.md
```

PDF, DOCX, PPTX, XLSX and HTML, five separate `docling-convert` calls, five clean `.md` files sitting right next to their originals — no manual export-to-Markdown step in Word or PowerPoint, no online converter to trust with the content.

## What Docling Does Differently

> **Same output, a very different engine:**
>
> **For every format in this article except PDF** — `.docx`, `.xlsx`, `.pptx` and `.html` — Docling does what every other converter does: it reads the file with a format-specific Python library and walks the result. Nothing exotic, no model loaded, and **nothing that needs a GPU** — `--pipeline vlm` is simply ignored on those four.
>
> The PDF path is where it diverges. A PDF has no structure to walk: it is a bag of glyphs at coordinates, and every converter has to *infer* where the paragraphs, headings and tables were. Docling infers it with models — one for page layout, one for table structure, one for OCR, and optionally a vision-language model that reads the rendered page the way a person would. That last one is the reason this article has a GPU in it at all.

As always, I'll build a Docker image first — no Python, no `pip`, nothing installed globally on my machine.

## Installation

### Create our Docker image

Two files get a permanent home, `~/tools/docling`, so the image can be rebuilt any time (new machine, `docker image prune`, a new Docling version): the `Dockerfile`, and `docling-convert.sh`, the script that drives a conversion (detailed below):

**Project: \~/tools/docling**

> **Note:**
>
> Now, run 'docker build -t docling \~/tools/docling' to create the Docker image.

**Dockerfile**

```docker
# syntax=docker/dockerfile:1

# Pinned down to the Debian release, not just "slim": the runtime package names
# below moved between Debian 12 and 13 (libglib2.0-0 became libglib2.0-0t64),
# so a floating base would break the build without warning
FROM python:3.12-slim-trixie AS final

# Prevent Python from writing .pyc files, buffer logs, and skip pip update checks
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1 \
    DOCLING_ARTIFACTS_PATH=/opt/docling-models

# Latest release at the time of writing; bump it deliberately, not by accident
ARG DOCLING_VERSION=2.127.0

# libgl1 and libglib2.0-0t64: OpenCV, pulled in by the OCR stage, needs them at
# import time and the slim image ships neither. gcc and libc6-dev: the VLM
# pipeline runs through Triton, PyTorch's JIT compiler, which builds a small
# shared library on first use
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        libgl1 libglib2.0-0t64 \
        gcc libc6-dev && \
    rm -rf /var/lib/apt/lists/*

# Create an unprivileged user without a valid login shell for strict security. Its
# UID does not have to match yours: the wrapper script runs the container with
# "-u $(id -u):$(id -g)", so the process takes your own identity at run time and
# whatever it writes into the mounted folder already belongs to you
RUN groupadd -g 1000 developer && \
    useradd -m -u 1000 -g 1000 -s /usr/sbin/nologin developer

# The BuildKit cache mount keeps the wheels between rebuilds without leaving
# them in the image layer, so no --no-cache-dir here. The torch wheel pulled in
# here ships its own CUDA and cuDNN libraries, hence no nvidia/cuda base image:
# the only thing the container needs from the host is the driver, and --gpus
# injects that at run time
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install "docling==${DOCLING_VERSION}"

# Bake the models in now, while we are still root and the filesystem is still
# writable. At run time the container is unprivileged and read-only, so nothing
# can be downloaded any more — and the OCR engine would try to write into its
# own site-packages folder, not into $HOME/.cache, so mounting a volume over the
# home directory would not help either. DOCLING_ARTIFACTS_PATH tells Docling to
# read the models from here instead of reaching for the network
RUN docling-tools models download layout tableformer rapidocr \
        -o "${DOCLING_ARTIFACTS_PATH}" && \
    docling-tools models download-hf-repo ibm-granite/granite-docling-258M \
        -o "${DOCLING_ARTIFACTS_PATH}" && \
    chmod -R a+rX "${DOCLING_ARTIFACTS_PATH}"

# Docling's --quiet silences its own logger, but not the model stack underneath:
# Transformers still prints a pad_token_id advisory on every VLM run, and the Hub
# still draws a "Loading weights" bar. These switch both off at the source. Kept
# below the heavy layers so tuning it never invalidates the model download
ENV TRANSFORMERS_VERBOSITY=error \
    HF_HUB_DISABLE_PROGRESS_BARS=1

WORKDIR /workspace

USER developer

# Execute the CLI tool directly
ENTRYPOINT ["docling"]
```

**docling-convert.sh**

```bash
#!/usr/bin/env bash
# Converts documents to Markdown using the isolated docling Docker image, with
# GPU acceleration when the NVIDIA container runtime is available. Unlike
# md-convert, docling writes "<basename>.md" directly into the current directory
# rather than to stdout — the source format is auto-detected from the extension.
#
# The image already carries its layout, table, OCR and VLM models, so there is
# nothing to download here and nothing to persist between runs.
#
# --pipeline vlm is the slow, accurate path: it reads the page the way a human
# would instead of stitching text fragments, which is what keeps paragraphs and
# titles intact. Pass "--pipeline standard" as an extra argument for the fast
# one. --pdf-backend only applies to the standard pipeline, where the default
# ("threaded_docling_parse") drops the last line of every paragraph; pypdfium2
# rebuilds the text correctly and gives the cleanest spacing of the backends.
#
# --image-export-mode referenced overrides docling's own default ("embedded"),
# which inlines every picture as a base64 data: URI and bloats the Markdown.
# "referenced" writes them as PNG files in "<basename>_artifacts/" instead, and
# the links stay relative because of "--output ." below.
set -euo pipefail

if [[ $# -eq 0 ]]; then
    echo "Usage: docling-convert <file> [extra docling options]" >&2
    exit 1
fi

# True when "$1" is already among the extra options, either bare or as "--opt=value".
has_arg() {
    local needle="$1" arg
    shift
    for arg in "$@"; do
        [[ "${arg}" == "${needle}" || "${arg}" == "${needle}="* ]] && return 0
    done
    return 1
}

# Printed under the result of every PDF conversion, because the moment you want
# the flag list is the moment you have just read a disappointing file and are
# about to run the command again. Options already on the command line drop off
# the list, so a second attempt never suggests what it is already doing. PDFs
# only: on the other four formats none of these flags changes anything.
print_tip() {
    [[ "${DOCLING_CONVERT_NO_TIP:-}" == "1" ]] && return 0
    [[ "${1,,}" == *.pdf ]] || return 0

    local -a tips=()
    has_arg --pipeline "${@:2}" || tips+=("--pipeline standard (faster, rougher on titles)")
    has_arg --image-export-mode "${@:2}" || tips+=("--image-export-mode placeholder|embedded (drop the images, or inline them)")
    has_arg -v "${@:2}" || tips+=("-v (the full log)")
    ((${#tips[@]} == 0)) && return 0

    local joined
    joined="$(printf '%s, ' "${tips[@]}")"
    echo "💡 Not happy with the result? Run it again with ${joined%, }." >&2
    echo "💡 Hide this hint with DOCLING_CONVERT_NO_TIP=1." >&2
}

# Without the NVIDIA container runtime, "docker run --gpus all" fails outright;
# skip the flag and let "--device auto" settle on the CPU instead.
gpu_args=()
if docker info --format '{{json .Runtimes}}' 2>/dev/null | grep -q '"nvidia"'; then
    gpu_args+=(--gpus all)
else
    echo "docling-convert: no NVIDIA container runtime detected, converting on CPU" >&2
fi

# Triton compiles a shared library on first use and then has to load it, which a
# noexec filesystem forbids. Rather than loosen /tmp, give it 256 MB of its own
# that allows execution — and nothing else.
#
# That tmpfs doubles as HOME. The container runs under the caller's UID, which
# has no entry in /etc/passwd, so HOME would otherwise be empty: PyTorch then
# resolves its kernel cache to "//.cache/torch/kernels", fails to create it, and
# recompiles its CUDA kernels on every single run.
#
# --quiet drops the two dozen INFO lines Docling logs per run, leaving warnings
# and errors. It is not a dead end: Docling ignores --quiet as soon as -v is
# given, so "docling-convert file.pdf -v" still brings the whole log back.
#
# The awk filter handles what --quiet cannot. In 2.127.0, VlmConvertModel.__del__
# logs through a module global that Python has already torn down by the time it
# runs, so every VLM conversion ends on an "Exception ignored in" traceback —
# after the Markdown has been written, and with the exit code still 0. It is
# noise, not a failure. The rule below drops that one block, keyed on its exact
# first line, and passes everything else through. Delete it when upstream fixes
# __del__; it is pinned to a version for exactly that reason.
start=${SECONDS}
set +e
docker run --rm \
    "${gpu_args[@]}" \
    -v "${PWD}:/workspace:cached" \
    --read-only \
    --tmpfs /tmp:size=512m,noexec,nosuid \
    --tmpfs /run:size=16m \
    --tmpfs /cache:size=256m,exec,nosuid \
    -e HOME=/cache \
    -e TRITON_CACHE_DIR=/cache/triton \
    --cap-drop ALL \
    --security-opt no-new-privileges:true \
    -u "$(id -u):$(id -g)" \
    docling convert "$1" \
        --to md \
        --device auto \
        --pipeline vlm \
        --pdf-backend pypdfium2 \
        --image-export-mode referenced \
        --output . \
        --quiet \
        "${@:2}" 2>&1 | awk '
            /^Exception ignored in: <function VlmConvertModel\.__del__/ { skip = 1; n = 0; next }
            skip { n++; if (/^AttributeError: / || n >= 8) skip = 0; next }
            { print }
        ' >&2
status=${PIPESTATUS[0]}
set -e

# In referenced mode docling creates "<basename>_artifacts/" for every run, even
# for a document without a single picture in it. rmdir only succeeds on an empty
# directory, so this clears the stray folders and can never touch one that holds
# actual images.
artifacts="$(basename "${1%.*}")_artifacts"
if [[ -d "${artifacts}" ]]; then
    rmdir "${artifacts}" 2>/dev/null || true
fi

# Docling no longer announces what it produced, so the wrapper does — one line,
# with the elapsed time, because the whole point of the pipeline choice above is
# the trade it makes against the clock.
if [[ ${status} -eq 0 ]]; then
    echo "docling-convert: $(basename "${1%.*}").md written in $((SECONDS - start))s" >&2
fi

print_tip "$@"

exit "${status}"
```

`ARG DOCLING_VERSION` pins the exact release rather than letting `pip` grab whatever is newest at build time — Docling ships a new version every few days, and an unpinned build is a different image every time you rebuild it. Bump that `ARG` when you actually want the newer one.

> **One package name, a whole shopping list behind it:**
>
> Since spring 2026, `docling` on PyPI is a thin metapackage: installing it pulls `docling-slim[standard]`, and *that* is what brings in the PDF, DOCX, PPTX, XLSX and HTML backends, PyTorch and the layout models. So a plain `pip install docling` still covers the five formats this article is about, with no extras to remember — but it is also why the image is heavy. If you only ever convert, say, `.docx`, `pip install "docling-slim[cli,format-docx,models-local]"` gives you a much smaller image; the full extras list is in [the project's PyPI page](https://pypi.org/project/docling-slim/).

### Build the image

No `compose.yaml` here: conversions go through a shell function that mounts whichever directory you are standing in, which a Compose file sitting in a fixed folder cannot do. So a plain build is all there is:

```bash
$ docker build -t docling ~/tools/docling
```

Expect this one to take noticeably longer than Markitdown's: PyTorch, its bundled CUDA libraries and the four baked-in models add up to an 8.3 GB image here. Those models are the part worth paying attention to — they are downloaded once, at build time, and they live *in* the image, so a `--rm` container starts with everything it needs and fetches nothing.

Optionally, if you want to test it to make sure the image is correct, just run `docker run --rm docling --help` — [the documentation](https://docling-project.github.io/docling/) covers every flag it lists.

### Make `docling-convert` available everywhere

Same pattern as [`md-convert`](/blog/markitdown), adapted for Docling's actual CLI shape. The real work is done by `~/tools/docling/docling-convert.sh`, the script you just created next to the Dockerfile:

**\~/tools/docling/docling-convert.sh**

```bash
#!/usr/bin/env bash
# Converts documents to Markdown using the isolated docling Docker image, with
# GPU acceleration when the NVIDIA container runtime is available. Unlike
# md-convert, docling writes "<basename>.md" directly into the current directory
# rather than to stdout — the source format is auto-detected from the extension.
#
# The image already carries its layout, table, OCR and VLM models, so there is
# nothing to download here and nothing to persist between runs.
#
# --pipeline vlm is the slow, accurate path: it reads the page the way a human
# would instead of stitching text fragments, which is what keeps paragraphs and
# titles intact. Pass "--pipeline standard" as an extra argument for the fast
# one. --pdf-backend only applies to the standard pipeline, where the default
# ("threaded_docling_parse") drops the last line of every paragraph; pypdfium2
# rebuilds the text correctly and gives the cleanest spacing of the backends.
#
# --image-export-mode referenced overrides docling's own default ("embedded"),
# which inlines every picture as a base64 data: URI and bloats the Markdown.
# "referenced" writes them as PNG files in "<basename>_artifacts/" instead, and
# the links stay relative because of "--output ." below.
set -euo pipefail

if [[ $# -eq 0 ]]; then
    echo "Usage: docling-convert <file> [extra docling options]" >&2
    exit 1
fi

# True when "$1" is already among the extra options, either bare or as "--opt=value".
has_arg() {
    local needle="$1" arg
    shift
    for arg in "$@"; do
        [[ "${arg}" == "${needle}" || "${arg}" == "${needle}="* ]] && return 0
    done
    return 1
}

# Printed under the result of every PDF conversion, because the moment you want
# the flag list is the moment you have just read a disappointing file and are
# about to run the command again. Options already on the command line drop off
# the list, so a second attempt never suggests what it is already doing. PDFs
# only: on the other four formats none of these flags changes anything.
print_tip() {
    [[ "${DOCLING_CONVERT_NO_TIP:-}" == "1" ]] && return 0
    [[ "${1,,}" == *.pdf ]] || return 0

    local -a tips=()
    has_arg --pipeline "${@:2}" || tips+=("--pipeline standard (faster, rougher on titles)")
    has_arg --image-export-mode "${@:2}" || tips+=("--image-export-mode placeholder|embedded (drop the images, or inline them)")
    has_arg -v "${@:2}" || tips+=("-v (the full log)")
    ((${#tips[@]} == 0)) && return 0

    local joined
    joined="$(printf '%s, ' "${tips[@]}")"
    echo "💡 Not happy with the result? Run it again with ${joined%, }." >&2
    echo "💡 Hide this hint with DOCLING_CONVERT_NO_TIP=1." >&2
}

# Without the NVIDIA container runtime, "docker run --gpus all" fails outright;
# skip the flag and let "--device auto" settle on the CPU instead.
gpu_args=()
if docker info --format '{{json .Runtimes}}' 2>/dev/null | grep -q '"nvidia"'; then
    gpu_args+=(--gpus all)
else
    echo "docling-convert: no NVIDIA container runtime detected, converting on CPU" >&2
fi

# Triton compiles a shared library on first use and then has to load it, which a
# noexec filesystem forbids. Rather than loosen /tmp, give it 256 MB of its own
# that allows execution — and nothing else.
#
# That tmpfs doubles as HOME. The container runs under the caller's UID, which
# has no entry in /etc/passwd, so HOME would otherwise be empty: PyTorch then
# resolves its kernel cache to "//.cache/torch/kernels", fails to create it, and
# recompiles its CUDA kernels on every single run.
#
# --quiet drops the two dozen INFO lines Docling logs per run, leaving warnings
# and errors. It is not a dead end: Docling ignores --quiet as soon as -v is
# given, so "docling-convert file.pdf -v" still brings the whole log back.
#
# The awk filter handles what --quiet cannot. In 2.127.0, VlmConvertModel.__del__
# logs through a module global that Python has already torn down by the time it
# runs, so every VLM conversion ends on an "Exception ignored in" traceback —
# after the Markdown has been written, and with the exit code still 0. It is
# noise, not a failure. The rule below drops that one block, keyed on its exact
# first line, and passes everything else through. Delete it when upstream fixes
# __del__; it is pinned to a version for exactly that reason.
start=${SECONDS}
set +e
docker run --rm \
    "${gpu_args[@]}" \
    -v "${PWD}:/workspace:cached" \
    --read-only \
    --tmpfs /tmp:size=512m,noexec,nosuid \
    --tmpfs /run:size=16m \
    --tmpfs /cache:size=256m,exec,nosuid \
    -e HOME=/cache \
    -e TRITON_CACHE_DIR=/cache/triton \
    --cap-drop ALL \
    --security-opt no-new-privileges:true \
    -u "$(id -u):$(id -g)" \
    docling convert "$1" \
        --to md \
        --device auto \
        --pipeline vlm \
        --pdf-backend pypdfium2 \
        --image-export-mode referenced \
        --output . \
        --quiet \
        "${@:2}" 2>&1 | awk '
            /^Exception ignored in: <function VlmConvertModel\.__del__/ { skip = 1; n = 0; next }
            skip { n++; if (/^AttributeError: / || n >= 8) skip = 0; next }
            { print }
        ' >&2
status=${PIPESTATUS[0]}
set -e

# In referenced mode docling creates "<basename>_artifacts/" for every run, even
# for a document without a single picture in it. rmdir only succeeds on an empty
# directory, so this clears the stray folders and can never touch one that holds
# actual images.
artifacts="$(basename "${1%.*}")_artifacts"
if [[ -d "${artifacts}" ]]; then
    rmdir "${artifacts}" 2>/dev/null || true
fi

# Docling no longer announces what it produced, so the wrapper does — one line,
# with the elapsed time, because the whole point of the pipeline choice above is
# the trade it makes against the clock.
if [[ ${status} -eq 0 ]]; then
    echo "docling-convert: $(basename "${1%.*}").md written in $((SECONDS - start))s" >&2
fi

print_tip "$@"

exit "${status}"
```

It stays a bash script on purpose: your shell only loads a short function that checks the script and the image are there, then runs the script with `bash`, whether your own shell is zsh or bash:

**docling-convert.sh**

```bash
# docling-convert: convert a document of the current folder to Markdown with
# the docling Docker image. Works in zsh and bash: save it as
# ~/.zsh/fns/docling-convert.zsh or ~/.bash/fns/docling-convert.sh.
#
#   docling-convert report.pdf          -> report.md, next to report.pdf
#
# The real work (GPU probe, docling options, log filtering) is done by
# ~/tools/docling/docling-convert.sh, a bash script kept next to the
# Dockerfile; this function only checks that everything is in place.
docling-convert() {
    local image="docling"
    local build_dir="${HOME}/tools/docling"
    # Shown as is in the messages, so the command can be copied for any user.
    # shellcheck disable=SC2016
    local build_dir_shown='${HOME}/tools/docling'
    local script="${build_dir}/docling-convert.sh"
    local script_shown="${build_dir_shown}/docling-convert.sh"

    if ! command -v docker >/dev/null 2>&1; then
        printf '\e[31mError: docker is not installed or not in your PATH.\e[0m\n' >&2
        return 1
    fi

    if [[ ! -f "${script}" ]] || ! docker image inspect "${image}" >/dev/null 2>&1; then
        if [[ ! -f "${script}" ]]; then
            printf '\e[31mError: %s does not exist.\e[0m\n\n' "${script_shown}" >&2
        else
            printf '\e[31mError: the Docker image "%s" does not exist on this machine.\e[0m\n\n' "${image}" >&2
        fi
        printf 'How to continue:\n' >&2
        if [[ -f "${build_dir}/Dockerfile" && -f "${script}" ]]; then
            printf '  1. docker build -t %s "%s"\n' "${image}" "${build_dir_shown}" >&2
            printf '  2. Run your docling-convert command again.\n' >&2
        else
            printf '  1. Get the Dockerfile and docling-convert.sh from\n' >&2
            printf '     https://www.avonture.be/blog/docling and save them in %s\n' "${build_dir_shown}" >&2
            printf '  2. docker build -t %s "%s"\n' "${image}" "${build_dir_shown}" >&2
            printf '  3. Run your docling-convert command again.\n' >&2
        fi
        return 1
    fi

    bash "${script}" "$@"
}
```

**1. Create the file** in `~/.zsh/fns/`:

**Project: \~/.zsh/fns**

**docling-convert.zsh**

```bash
# docling-convert: convert a document of the current folder to Markdown with
# the docling Docker image. Works in zsh and bash: save it as
# ~/.zsh/fns/docling-convert.zsh or ~/.bash/fns/docling-convert.sh.
#
#   docling-convert report.pdf          -> report.md, next to report.pdf
#
# The real work (GPU probe, docling options, log filtering) is done by
# ~/tools/docling/docling-convert.sh, a bash script kept next to the
# Dockerfile; this function only checks that everything is in place.
docling-convert() {
    local image="docling"
    local build_dir="${HOME}/tools/docling"
    # Shown as is in the messages, so the command can be copied for any user.
    # shellcheck disable=SC2016
    local build_dir_shown='${HOME}/tools/docling'
    local script="${build_dir}/docling-convert.sh"
    local script_shown="${build_dir_shown}/docling-convert.sh"

    if ! command -v docker >/dev/null 2>&1; then
        printf '\e[31mError: docker is not installed or not in your PATH.\e[0m\n' >&2
        return 1
    fi

    if [[ ! -f "${script}" ]] || ! docker image inspect "${image}" >/dev/null 2>&1; then
        if [[ ! -f "${script}" ]]; then
            printf '\e[31mError: %s does not exist.\e[0m\n\n' "${script_shown}" >&2
        else
            printf '\e[31mError: the Docker image "%s" does not exist on this machine.\e[0m\n\n' "${image}" >&2
        fi
        printf 'How to continue:\n' >&2
        if [[ -f "${build_dir}/Dockerfile" && -f "${script}" ]]; then
            printf '  1. docker build -t %s "%s"\n' "${image}" "${build_dir_shown}" >&2
            printf '  2. Run your docling-convert command again.\n' >&2
        else
            printf '  1. Get the Dockerfile and docling-convert.sh from\n' >&2
            printf '     https://www.avonture.be/blog/docling and save them in %s\n' "${build_dir_shown}" >&2
            printf '  2. docker build -t %s "%s"\n' "${image}" "${build_dir_shown}" >&2
            printf '  3. Run your docling-convert command again.\n' >&2
        fi
        return 1
    fi

    bash "${script}" "$@"
}
```

**2. Load the folder from `~/.zshrc`.** Already done if you followed one of my ["Ollama daily use"](/series/ollama-daily-use) articles. Otherwise, add at the end of `~/.zshrc`:

```zsh title="~/.zshrc"
for fn_file in ~/.zsh/fns/*.zsh; do
  source "$fn_file"
done
```

**3. Open a new terminal** (or run `exec zsh`).

**1. Create the file** in `~/.bash/fns/`:

**Project: \~/.bash/fns**

**docling-convert.sh**

```bash
# docling-convert: convert a document of the current folder to Markdown with
# the docling Docker image. Works in zsh and bash: save it as
# ~/.zsh/fns/docling-convert.zsh or ~/.bash/fns/docling-convert.sh.
#
#   docling-convert report.pdf          -> report.md, next to report.pdf
#
# The real work (GPU probe, docling options, log filtering) is done by
# ~/tools/docling/docling-convert.sh, a bash script kept next to the
# Dockerfile; this function only checks that everything is in place.
docling-convert() {
    local image="docling"
    local build_dir="${HOME}/tools/docling"
    # Shown as is in the messages, so the command can be copied for any user.
    # shellcheck disable=SC2016
    local build_dir_shown='${HOME}/tools/docling'
    local script="${build_dir}/docling-convert.sh"
    local script_shown="${build_dir_shown}/docling-convert.sh"

    if ! command -v docker >/dev/null 2>&1; then
        printf '\e[31mError: docker is not installed or not in your PATH.\e[0m\n' >&2
        return 1
    fi

    if [[ ! -f "${script}" ]] || ! docker image inspect "${image}" >/dev/null 2>&1; then
        if [[ ! -f "${script}" ]]; then
            printf '\e[31mError: %s does not exist.\e[0m\n\n' "${script_shown}" >&2
        else
            printf '\e[31mError: the Docker image "%s" does not exist on this machine.\e[0m\n\n' "${image}" >&2
        fi
        printf 'How to continue:\n' >&2
        if [[ -f "${build_dir}/Dockerfile" && -f "${script}" ]]; then
            printf '  1. docker build -t %s "%s"\n' "${image}" "${build_dir_shown}" >&2
            printf '  2. Run your docling-convert command again.\n' >&2
        else
            printf '  1. Get the Dockerfile and docling-convert.sh from\n' >&2
            printf '     https://www.avonture.be/blog/docling and save them in %s\n' "${build_dir_shown}" >&2
            printf '  2. docker build -t %s "%s"\n' "${image}" "${build_dir_shown}" >&2
            printf '  3. Run your docling-convert command again.\n' >&2
        fi
        return 1
    fi

    bash "${script}" "$@"
}
```

**2. Load the folder from `~/.bashrc`.** Add at the end of `~/.bashrc` (the `-r` test keeps an empty folder from printing an error):

```bash title="~/.bashrc"
for fn_file in ~/.bash/fns/*.sh; do
  [[ -r "$fn_file" ]] && source "$fn_file"
done
```

**3. Open a new terminal** (or run `exec bash`).

Check that the shell knows the function: `type docling-convert` must answer `docling-convert is a shell function`.

If the image or the script is missing, `docling-convert` prints the exact `docker build` command to run, or points back to this article when `~/tools/docling` is gone too.

> **Coming from the previous version of this article?:**
>
> It installed `docling-convert` as a script in `/usr/local/bin/`. Remove it (`sudo rm /usr/local/bin/docling-convert`): the function takes over, and the old script would otherwise linger on your disk.

> **Why a wrapper?:**
>
> The script probes `docker info` for the NVIDIA runtime before adding `--gpus all`. That check is not decoration: `docker run --gpus all` doesn't degrade politely on a machine without the toolkit, it refuses to start the container at all. With the probe in place, the same script works on my GPU server and on my laptop — slower on the laptop, that's all, and nothing to configure on either. Setting that runtime up is optional and comes at the end of this article.

Left alone, Docling narrates every run — engine selection, model loading, token counts, twenty-odd `INFO` lines to convert a single page — which is why the wrapper passes `--quiet`. That is not a dead end: Docling ignores `--quiet` as soon as `-v` is present, so `docling-convert report.pdf -v` brings the whole log back on the day something actually goes wrong. The two `TRANSFORMERS_*` and `HF_HUB_*` variables in the Dockerfile finish the job, silencing the model stack underneath, which has its own opinions about progress bars.

### First conversion

Time to check the whole chain holds together. The five files used throughout this article are here — one document, a short delivery note, expressed five ways, so you can see what each format costs on the way to Markdown rather than compare five unrelated things:

samples.zip — the five files: `?`

Prefer to stay in the terminal? The archive sits at a fixed URL, so `curl` fetches it straight into a scratch folder, `/tmp/docling`, unzipped into a `samples` subfolder:

```bash
$ mkdir -p /tmp/docling && cd /tmp/docling

$ curl -L --silent -o samples.zip https://www.avonture.be/files/docling/samples.zip

$ unzip -d samples samples.zip && rm samples.zip && cd samples

Archive:  samples.zip
  inflating: samples/report.pdf
  inflating: samples/contract.docx
  inflating: samples/slides.pptx
  inflating: samples/budget.xlsx
  inflating: samples/page.html

$ ls

budget.xlsx  contract.docx  page.html  report.pdf  slides.pptx

$ docling-convert report.pdf

docling-convert: report.md written in 14s
💡 Not happy with the result? Run it again with --pipeline standard (faster, rougher on titles), --image-export-mode placeholder|embedded (drop the images, or inline them), -v (the full log).
💡 Hide this hint with DOCLING_CONVERT_NO_TIP=1.

$ ls

budget.xlsx  contract.docx  page.html  report.md  report.pdf  slides.pptx
```

`report.md` lands next to `report.pdf`, title and paragraphs intact — no output folder to name, no redirection to remember.

Two things are worth noticing there. The first is that `docling-convert report.pdf` has taken fourteen seconds. The second is the list of flags printed under the result: it shows up under every PDF conversion, options you already passed drop off it, the other four formats get no hint at all, and `DOCLING_CONVERT_NO_TIP=1` stops it for good.

Which leaves the obvious question: what do those fourteen seconds buy?

### Why the slow pipeline is the default

Docling can read a PDF two ways. The standard pipeline detects regions, pulls the text out of each, and reassembles them. The `vlm` pipeline hands the rendered page to a small vision-language model — GraniteDocling, 258M parameters — and asks it to describe the structure, the way a person reading the page would. That is the one the wrapper uses, and here is `report.pdf` — four justified paragraphs under a display-font title — through both:

```bash
$ export DOCLING_CONVERT_NO_TIP=1

$ docling-convert report.pdf --pipeline standard
docling-convert: report.md written in 8s

$ cat report.md

## Quarterly Delivery Note d titiitl iltil

y y
Scope, milestones and termination — internal circulation only
Following the quarterly review held on 3 September, this note records the agreed
scope, [...] the payment schedule set out in section three,
which is intended to replace it entirely.
The supplier shall deliver the first increment within six weeks of signature, [...]

$ docling-convert report.pdf
docling-convert: report.md written in 14s

$ cat report.md

## Quarterly Delivery Note

Scope, milestones and termination - internal circulation only
Following the quarterly review held on 3 September, this note records the agreed
scope, [...] the payment schedule set out in section three, which is intended to
replace it entirely.
The supplier shall deliver the first increment within six weeks of signature, [...]
```

Look at the standard pipeline's first line. `Quarterly Delivery Note d titiitl iltil`, followed by a stray `yy` — the OCR stage fired on the large title and hallucinated letters that are in no version of that document. Then it broke the first clause in two, stranding `which is intended to replace it entirely.` on its own line. The VLM produced neither the invented text nor the orphan. Fourteen seconds against eight is the price, and it buys a file you can hand to someone without reading it first.

The fast path does not fail loudly: it invents, and invented text is only caught by re-reading the output.

> **One real difference from md-convert:**
>
> Markitdown prints Markdown to stdout, so `md-convert file.docx > file.md` is how you capture it. Docling's CLI writes `<basename>.md` straight into the output directory instead — there's no stdout mode. `docling-convert file.docx` produces `file.md` next to it directly; no `>` redirection needed, and none will work.

### A word on images

Docling renders anything the layout model classifies as a picture — and on a title set in a display font, that can be the title itself. Left alone it then embeds each one in the Markdown as a base64 `data:` URI, which keeps the file self-contained and inflates it enormously. The wrapper passes `--image-export-mode referenced` instead: the pictures land as real PNG files in a `budget_artifacts/` folder next to `budget.md`, linked relatively, so the two travel together.

The other two modes are one flag away. `--image-export-mode placeholder` *drops* the images and leaves a `<!-- image -->` marker where each one was — the right choice when the Markdown is going to feed a model rather than a person. `--image-export-mode embedded` brings the base64 file back, for when you want a single self-contained document. One thing to know if you ever call Docling without the wrapper: those links are relative because of `--output .`, and an absolute `--output` writes the container's own paths instead, which resolve to nothing outside it.

## Optional — Putting a GPU Behind the PDF Pipeline

Everything above works without a GPU, and this section is skippable — twice over:

* **You only convert `.docx`, `.xlsx`, `.pptx` or `.html`?** Then it buys you strictly nothing. Those four formats never load a model, so there is nothing to accelerate; `--gpus` changes neither the speed nor the output.
* **You convert PDFs on a machine with no NVIDIA card?** Then it still works. `--device auto` settles on the CPU, the same VLM pipeline runs, and the same Markdown comes out — it just takes longer. The wrapper prints the elapsed time on every run, so your own hardware will tell you how much longer.

Recommended for PDFs, pointless for the other four, mandatory for nobody.

### Why a toolkit is needed at all

A container does not see your graphics card. The card, its VRAM and the NVIDIA *driver* live on the host; a container gets neither `/dev/nvidia*` in its device list nor the driver's libraries in its filesystem. The CUDA libraries that `pip` installed in our image — the `torch` wheel carries its own, which is why the Dockerfile starts from a plain `python:3.12-slim-trixie` and not from an `nvidia/cuda` base — are only the *upper* half of that stack: they talk to the driver, they do not replace it. With the lower half missing, PyTorch reports no CUDA device and computes on the CPU.

The [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) is the piece that joins the two halves: it registers a hook in the Docker daemon which, each time a container starts with `--gpus`, injects the host's GPU device nodes and driver libraries into it. That is the whole reason it appears in this article — **no toolkit, no VRAM**. Install it on the Docker host, never in the image, then confirm the GPU is visible from inside a container:

```bash
$ docker run --rm --gpus all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi

Tue Sep 15 19:20:44 2026
+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 595.71.01              Driver Version: 596.36         CUDA Version: 13.2     |
+-----------------------------------------+------------------------+----------------------+
| GPU  Name                 Persistence-M | Bus-Id          Disp.A | Volatile Uncorr. ECC |
| Fan  Temp   Perf          Pwr:Usage/Cap |           Memory-Usage | GPU-Util  Compute M. |
|                                         |                        |               MIG M. |
|=========================================+========================+======================|
|   0  NVIDIA RTX PRO 4000 Blac...    On  |   00000000:01:00.0  On |                  Off |
| 30%   34C    P8              6W /  145W |     892MiB /  24467MiB |      0%      Default |
|                                         |                        |                  N/A |
+-----------------------------------------+------------------------+----------------------+

+-----------------------------------------------------------------------------------------+
| Processes:                                                                              |
|  GPU   GI   CI              PID   Type   Process name                        GPU Memory |
|        ID   ID                                                               Usage      |
|=========================================================================================|
|  No running processes found                                                             |
+-----------------------------------------------------------------------------------------+
```

That table is the host's card read from *inside* a container: 24 GB of VRAM, driver version, what is currently using it. The `nvidia/cuda` image there is just a convenient carrier for `nvidia-smi` — our own image doesn't need it.

### If that table doesn't show up

Nothing is broken: `docling-convert` keeps converting, on the CPU, exactly as it did through the whole article. If you do want the GPU, three failures cover nearly every case:

| What you get instead                                           | What it means                                                                                                 | What to do                                                                                                                                                                           |
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `could not select device driver "" with capabilities: [[gpu]]` | Docker has no GPU hook: the toolkit is either not installed, or installed and never wired into the daemon     | Install the toolkit from [NVIDIA's package repository](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html), then run the two commands below |
| `nvidia-smi` fails **on the host too**, outside any container  | The driver itself is missing or broken — Docker isn't in play yet                                             | Install the driver and reboot until `nvidia-smi` works on the host. On WSL2 that driver is a **Windows** driver: install it on Windows, never inside the distribution                |
| `Failed to initialize NVML: Unknown Error`                     | The hook ran, but the container was refused the device — a daemon still running its pre-toolkit configuration | The two commands below                                                                                                                                                               |

Two of those three are the same fix, because the package only puts the hook on disk — the daemon still has to be told about it:

```bash
$ sudo nvidia-ctk runtime configure --runtime=docker
$ sudo systemctl restart docker

$ docker info --format '{{json .Runtimes}}'
{"io.containerd.runc.v2":{"path":"runc"},"nvidia":{"path":"nvidia-container-runtime"},"runc":{"path":"runc"}}
```

That last command is the exact probe the wrapper script runs before adding `--gpus all`: once `nvidia` appears among Docker's runtimes, the next PDF goes through the GPU with nothing else to change.

## What the Five Files Cost

Every number in this article was measured on the files you unzipped earlier, and the five commands from the very first terminal run on them as written. On my machine the four office formats land in four to five seconds each, almost all of it process start-up, and `report.pdf` takes fourteen with the GPU in play. That gap is the whole story: only the PDF needs the models.

> **What the spreadsheet actually shows:**
>
> Merged cells are not collapsed, they are *repeated*: the title comes out once per column it spanned, and so does each section row. Nothing is lost, but do not expect Markdown to reproduce the visual merge. And Docling reads formulas, not their results — a spreadsheet written by a script and never opened in Excel converts with every total empty. `budget.xlsx` stores computed values for that reason.

## Key Takeaways

**docling-convert quick reference**

* **GPU is optional** — `--device auto` falls back to CPU cleanly if passthrough isn't configured

* **One package, five formats** — `pip install docling` pulls `docling-slim[standard]`, which covers PDF, DOCX, PPTX, XLSX and HTML

* **Models are baked in** — downloaded at build time, so a read-only unprivileged container never has to fetch anything

* **Writes files, not stdout** — `docling-convert file.pdf` produces `file.md` directly, no `>` redirection

* **Slow on purpose** — `--pipeline vlm` keeps paragraphs and titles intact; add `--pipeline standard` when speed matters more

* **Only PDFs need the models** — `.docx`/`.xlsx`/`.pptx`/`.html` go through a plain parser, no GPU and no model involved

## Conclusion

Between this and [Markitdown](/blog/markitdown), I now have two Docker images that turn office documents into Markdown without a single byte leaving my machine — a light one I reach for by reflex, and this heavier one for when a PDF's structure matters enough to spend twenty seconds and a GPU on it. And since both output plain Markdown, either one slots straight into the kind of pipeline I described in [Chat with your documents using AnythingLLM](/blog/anythingllm-chat-with-your-docs) — or into the next article in the "Ollama daily use" series, where that Markdown becomes the input to a local translation and summarization pipeline for documents that were never meant to touch the cloud in the first place.
