<!--
  canonical-url: https://www.avonture.be/fr/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 - Convertir PDF, Word, PowerPoint, Excel et HTML en Markdown, accéléré par GPU

> Une image Docker batteries-included pour Docling, la bibliothèque de conversion de documents d'IBM : PDF, Word, PowerPoint, Excel et HTML transformés en Markdown propre, avec accélération GPU pour les machines qui ont de la VRAM en rab. Un complément à mon article Markitdown — même idée, un moteur plus lourd en dessous.

---
![Docling - Convertir PDF, Word, PowerPoint, Excel et HTML en Markdown, accéléré par GPU](/img/v2/docling.webp)

**TL;DR**

Cet article fait pour [Docling](https://github.com/docling-project/docling) exactement ce que [mon article sur Markitdown](/blog/markitdown) faisait pour Markitdown : une image Docker batteries-included et une fonction shell `docling-convert` pour zsh ou bash, afin que convertir un document en Markdown propre tienne en une commande depuis n'importe quel dossier. **La différence est sous le capot — Docling utilise des modèles dédiés de layout, de structure de tableaux et d'OCR plutôt que des parsers spécifiques à chaque format, tourne sur GPU si vous en avez un, et est conçu dès le départ exactement pour le scénario « document sensible, qui doit rester en local » auquel ce blog revient sans cesse.**

Un ami qui lisait [mon article sur Markitdown](/blog/markitdown) m'a suggéré d'essayer [Docling](https://docling.ai/) à la place — projet open source, du côté d'IBM cette fois, même promesse « convertir des documents bureautiques en Markdown », mais construit autour de vrais modèles de compréhension de mise en page plutôt que de parsers par format. J'ai 24 Go de VRAM qui dorment sur mon serveur IA ; si un outil peut les mettre au travail pour transformer un PDF mal scanné en quelque chose de lisible, ça vaut dix minutes de test.

## Convertir cinq formats {#converting-five-formats}

Une fois l'image et la fonction `docling-convert` en place (on voit ça plus bas), convertir un document tient en une commande :

```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 et HTML, cinq appels distincts à `docling-convert`, cinq fichiers `.md` propres posés à côté de leurs originaux — pas d'export-vers-Markdown manuel dans Word ou PowerPoint, pas de convertisseur en ligne à qui confier le contenu.

## Ce que Docling fait différemment {#what-docling-does-differently}

> **Même sortie, un moteur très différent:**
>
> **Pour tous les formats de cet article sauf le PDF** — `.docx`, `.xlsx`, `.pptx` et `.html` — Docling fait ce que fait tout autre convertisseur : il lit le fichier avec une bibliothèque Python dédiée au format et parcourt le résultat. Rien d'exotique, aucun modèle chargé, et **rien qui nécessite un GPU** — `--pipeline vlm` est simplement ignoré sur ces quatre-là.
>
> C'est sur le chemin PDF que ça diverge. Un PDF n'a aucune structure à parcourir : c'est un sac de glyphes à des coordonnées, et chaque convertisseur doit *deviner* où se trouvaient les paragraphes, les titres et les tableaux. Docling le déduit avec des modèles — un pour la mise en page, un pour la structure des tableaux, un pour l'OCR, et en option un modèle vision-langage qui lit la page rendue comme le ferait un humain. C'est ce dernier qui justifie la présence d'un GPU dans cet article.

Comme toujours, je vais d'abord construire une image Docker — pas de Python, pas de `pip`, rien d'installé globalement sur ma machine.

## Installation {#installation}

### Créer notre image Docker {#create-our-docker-image}

Deux fichiers obtiennent un emplacement permanent, `~/tools/docling`, pour que l'image puisse être reconstruite à tout moment (nouvelle machine, `docker image prune`, nouvelle version de Docling) : le `Dockerfile`, et `docling-convert.sh`, le script qui pilote une conversion (détaillé plus bas) :

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

> **Note:**
>
> Maintenant, lancez 'docker build -t docling \~/tools/docling' pour créer l'image Docker.

**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` fige la release exacte au lieu de laisser `pip` attraper la plus récente au moment du build — Docling sort une nouvelle version tous les quelques jours, et un build non figé donne une image différente à chaque reconstruction. Montez cet `ARG` quand vous voulez réellement la plus récente.

> **Un nom de package, toute une liste de courses derrière:**
>
> Depuis le printemps 2026, `docling` sur PyPI est un metapackage léger : l'installer tire `docling-slim[standard]`, et c'est *ça* qui amène les backends PDF, DOCX, PPTX, XLSX et HTML, PyTorch et les modèles de layout. Donc un simple `pip install docling` couvre toujours les cinq formats dont parle cet article, sans extras à retenir — mais c'est aussi pour ça que l'image est lourde. Si vous ne convertissez jamais que du `.docx`, par exemple, `pip install "docling-slim[cli,format-docx,models-local]"` vous donne une image beaucoup plus petite ; la liste complète des extras est sur [la page PyPI du projet](https://pypi.org/project/docling-slim/).

### Construire l'image {#build-the-image}

Pas de `compose.yaml` ici : les conversions passent par une fonction shell qui monte le répertoire dans lequel vous vous trouvez, ce qu'un fichier Compose posé dans un dossier fixe ne peut pas faire. Un simple build suffit donc :

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

Attendez-vous à ce que celui-ci soit nettement plus long que celui de Markitdown : PyTorch, ses bibliothèques CUDA embarquées et les quatre modèles intégrés montent à une image de 8,3 Go ici. Ces modèles sont la partie qui mérite l'attention — ils sont téléchargés une seule fois, au build, et ils vivent *dans* l'image, donc un container `--rm` démarre avec tout ce qu'il lui faut et ne télécharge rien.

Optionnellement, si vous voulez vérifier que l'image est correcte, lancez simplement `docker run --rm docling --help` — [la documentation](https://docling-project.github.io/docling/) couvre chaque flag listé.

### Rendre `docling-convert` disponible partout {#make-docling-convert-available-everywhere}

Même principe que [`md-convert`](/blog/markitdown), adapté à la forme réelle de la CLI de Docling. Le vrai travail est fait par `~/tools/docling/docling-convert.sh`, le script que vous venez de créer à côté du 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}"
```

Il reste un script bash volontairement : votre shell ne charge qu'une courte fonction qui vérifie que le script et l'image sont là, puis lance le script avec `bash`, que votre propre shell soit zsh ou 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. Créez le fichier** dans `~/.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. Chargez le dossier depuis `~/.zshrc`.** Déjà fait si vous avez suivi un de mes articles « Ollama au quotidien ». Sinon, ajoutez à la fin de `~/.zshrc` :

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

**3. Ouvrez un nouveau terminal** (ou lancez `exec zsh`).

**1. Créez le fichier** dans `~/.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. Chargez le dossier depuis `~/.bashrc`.** Ajoutez à la fin de `~/.bashrc` (le test `-r` évite qu'un dossier vide n'affiche une erreur) :

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

**3. Ouvrez un nouveau terminal** (ou lancez `exec bash`).

Vérifiez que le shell connaît la fonction : `type docling-convert` doit répondre `docling-convert is a shell function`.

Si l'image ou le script manque, `docling-convert` affiche la commande `docker build` exacte à lancer, ou renvoie vers cet article quand `~/tools/docling` a disparu aussi.

> **Vous venez de la version précédente de cet article ?:**
>
> Elle installait `docling-convert` comme script dans `/usr/local/bin/`. Supprimez-le (`sudo rm /usr/local/bin/docling-convert`) : la fonction prend le relais, et l'ancien script traînerait sinon sur votre disque.

> **Pourquoi un wrapper ?:**
>
> Le script interroge `docker info` pour détecter le runtime NVIDIA avant d'ajouter `--gpus all`. Cette vérification n'est pas décorative : `docker run --gpus all` ne se dégrade pas poliment sur une machine sans le toolkit, il refuse carrément de démarrer le container. Avec la sonde en place, le même script fonctionne sur mon serveur GPU et sur mon portable — plus lentement sur le portable, c'est tout, et rien à configurer dans les deux cas. Mettre ce runtime en place est optionnel et arrive à la fin de cet article.

Laissé tranquille, Docling raconte chaque exécution — choix du moteur, chargement des modèles, comptage des tokens, une vingtaine de lignes `INFO` pour convertir une seule page — d'où le `--quiet` passé par le wrapper. Ce n'est pas une impasse : Docling ignore `--quiet` dès que `-v` est présent, donc `docling-convert report.pdf -v` ramène tout le log le jour où quelque chose tourne mal. Les deux variables `TRANSFORMERS_*` et `HF_HUB_*` du Dockerfile finissent le travail, en faisant taire la couche de modèles en dessous, qui a ses propres opinions sur les barres de progression.

### Première conversion {#first-conversion}

Il est temps de vérifier que toute la chaîne tient. Les cinq fichiers utilisés tout au long de cet article sont ici — un document, un court bon de livraison, exprimé de cinq façons, pour que vous voyiez ce que chaque format coûte sur le chemin du Markdown plutôt que de comparer cinq choses sans rapport :

samples.zip — les cinq fichiers: `?`

Vous préférez rester dans le terminal ? L'archive est à une URL fixe, donc `curl` la récupère directement dans un dossier temporaire, `/tmp/docling`, dézippée dans un sous-dossier `samples` :

```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` atterrit à côté de `report.pdf`, titre et paragraphes intacts — pas de dossier de sortie à nommer, pas de redirection à retenir.

Deux choses méritent attention ici. La première, c'est que `docling-convert report.pdf` a pris quatorze secondes. La seconde, c'est la liste de flags affichée sous le résultat : elle apparaît sous chaque conversion PDF, les options que vous avez déjà passées en disparaissent, les quatre autres formats n'ont droit à aucune suggestion, et `DOCLING_CONVERT_NO_TIP=1` la coupe définitivement.

Reste la question évidente : qu'est-ce qu'on achète avec ces quatorze secondes ?

### Pourquoi le pipeline lent est celui par défaut {#why-the-slow-pipeline-is-the-default}

Docling peut lire un PDF de deux manières. Le pipeline standard détecte les régions, en extrait le texte, puis réassemble le tout. Le pipeline `vlm` confie la page rendue à un petit modèle vision-langage — GraniteDocling, 258M de paramètres — et lui demande d'en décrire la structure, comme le ferait une personne qui lit la page. C'est celui qu'utilise le wrapper, et voici `report.pdf` — quatre paragraphes justifiés sous un titre en police display — passé par les deux :

```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, [...]
```

Regardez la première ligne du pipeline standard. `Quarterly Delivery Note d titiitl iltil`, suivi d'un `yy` égaré — l'étape OCR s'est déclenchée sur le grand titre et a halluciné des lettres qui n'existent dans aucune version de ce document. Puis elle a coupé la première proposition en deux, laissant `which is intended to replace it entirely.` seule sur sa ligne. Le VLM n'a produit ni le texte inventé ni l'orpheline. Quatorze secondes contre huit, c'est le prix, et ça achète un fichier que vous pouvez transmettre à quelqu'un sans l'avoir relu.

Le chemin rapide n'échoue pas bruyamment : il invente, et le texte inventé ne se détecte qu'en relisant la sortie.

> **Une vraie différence avec md-convert:**
>
> Markitdown écrit le Markdown sur stdout, donc `md-convert file.docx > file.md` est la façon de le capturer. La CLI de Docling écrit au contraire `<basename>.md` directement dans le répertoire de sortie — il n'y a pas de mode stdout. `docling-convert file.docx` produit `file.md` juste à côté ; aucune redirection `>` n'est nécessaire, et aucune ne fonctionnera.

### Un mot sur les images {#a-word-on-images}

Docling rend tout ce que le modèle de layout classe comme image — et sur un titre composé dans une police display, ça peut être le titre lui-même. Laissé tranquille, il intègre ensuite chacune d'elles dans le Markdown sous forme d'URI `data:` en base64, ce qui garde le fichier autonome et le gonfle énormément. Le wrapper passe `--image-export-mode referenced` à la place : les images atterrissent en vrais fichiers PNG dans un dossier `budget_artifacts/` à côté de `budget.md`, liées en relatif, pour que les deux voyagent ensemble.

Les deux autres modes sont à un flag de distance. `--image-export-mode placeholder` *supprime* les images et laisse un marqueur `<!-- image -->` à la place de chacune — le bon choix quand le Markdown va alimenter un modèle plutôt qu'une personne. `--image-export-mode embedded` ramène le base64, quand vous voulez un document unique et autonome. Une chose à savoir si vous appelez un jour Docling sans le wrapper : ces liens sont relatifs grâce à `--output .`, et un `--output` absolu écrit les chemins internes au container, qui ne résolvent vers rien à l'extérieur.

## Optionnel — Mettre un GPU derrière le pipeline PDF {#optional--putting-a-gpu-behind-the-pdf-pipeline}

Tout ce qui précède fonctionne sans GPU, et cette section est sautable — deux fois plutôt qu'une :

* **Vous ne convertissez que du `.docx`, `.xlsx`, `.pptx` ou `.html` ?** Alors ça ne vous apporte strictement rien. Ces quatre formats ne chargent jamais de modèle, il n'y a donc rien à accélérer ; `--gpus` ne change ni la vitesse ni la sortie.
* **Vous convertissez des PDF sur une machine sans carte NVIDIA ?** Alors ça fonctionne quand même. `--device auto` se rabat sur le CPU, le même pipeline VLM tourne, et le même Markdown en sort — c'est juste plus long. Le wrapper affiche le temps écoulé à chaque exécution, votre propre matériel vous dira donc de combien.

Recommandé pour les PDF, inutile pour les quatre autres, obligatoire pour personne.

### Pourquoi un toolkit est nécessaire {#why-a-toolkit-is-needed-at-all}

Un container ne voit pas votre carte graphique. La carte, sa VRAM et le *driver* NVIDIA vivent sur l'host ; un container n'obtient ni `/dev/nvidia*` dans sa liste de périphériques, ni les bibliothèques du driver dans son système de fichiers. Les bibliothèques CUDA que `pip` a installées dans notre image — la wheel `torch` embarque les siennes, raison pour laquelle le Dockerfile part d'un simple `python:3.12-slim-trixie` et non d'une base `nvidia/cuda` — ne sont que la moitié *supérieure* de cette pile : elles parlent au driver, elles ne le remplacent pas. La moitié inférieure manquant, PyTorch ne signale aucun périphérique CUDA et calcule sur le CPU.

Le [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) est la pièce qui réunit les deux moitiés : il enregistre un hook dans le daemon Docker qui, à chaque démarrage de container avec `--gpus`, y injecte les nœuds de périphériques GPU et les bibliothèques du driver de l'host. C'est toute la raison de sa présence dans cet article — **pas de toolkit, pas de VRAM**. Installez-le sur l'host Docker, jamais dans l'image, puis confirmez que le GPU est visible depuis l'intérieur d'un 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                                                             |
+-----------------------------------------------------------------------------------------+
```

Ce tableau, c'est la carte de l'host lue depuis *l'intérieur* d'un container : 24 Go de VRAM, version du driver, ce qui l'utilise actuellement. L'image `nvidia/cuda` n'est là que comme véhicule pratique pour `nvidia-smi` — notre propre image n'en a pas besoin.

### Si ce tableau n'apparaît pas {#if-that-table-doesnt-show-up}

Rien n'est cassé : `docling-convert` continue de convertir, sur le CPU, exactement comme il l'a fait tout au long de l'article. Si vous voulez vraiment le GPU, trois pannes couvrent quasiment tous les cas :

| Ce que vous obtenez à la place                                        | Ce que ça veut dire                                                                                                                          | Quoi faire                                                                                                                                                                                        |
| --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `could not select device driver "" with capabilities: [[gpu]]`        | Docker n'a pas de hook GPU : le toolkit n'est pas installé, ou bien installé mais jamais branché au daemon                                   | Installez le toolkit depuis [le dépôt de packages NVIDIA](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html), puis lancez les deux commandes ci-dessous |
| `nvidia-smi` échoue **sur l'host aussi**, en dehors de tout container | Le driver lui-même manque ou est cassé — Docker n'est pas encore en jeu                                                                      | Installez le driver et redémarrez jusqu'à ce que `nvidia-smi` fonctionne sur l'host. Sous WSL2, ce driver est un driver **Windows** : installez-le sur Windows, jamais dans la distribution       |
| `Failed to initialize NVML: Unknown Error`                            | Le hook s'est exécuté, mais le périphérique a été refusé au container — un daemon qui tourne encore avec sa configuration d'avant le toolkit | Les deux commandes ci-dessous                                                                                                                                                                     |

Deux de ces trois cas ont le même correctif, parce que le package ne fait que poser le hook sur le disque — il faut encore en informer le daemon :

```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"}}
```

Cette dernière commande est exactement la sonde que le script wrapper exécute avant d'ajouter `--gpus all` : dès que `nvidia` apparaît parmi les runtimes de Docker, le prochain PDF passe par le GPU sans rien d'autre à changer.

## Ce que coûtent les cinq fichiers {#what-the-five-files-cost}

Chaque chiffre de cet article a été mesuré sur les fichiers que vous avez dézippés plus haut, et les cinq commandes du tout premier terminal tournent dessus telles quelles. Sur ma machine, les quatre formats bureautiques tiennent en quatre à cinq secondes chacun, presque entièrement du démarrage de processus, et `report.pdf` prend quatorze secondes avec le GPU en jeu. Cet écart résume tout : seul le PDF a besoin des modèles.

> **Ce que montre réellement le tableur:**
>
> Les cellules fusionnées ne sont pas réduites, elles sont *répétées* : le titre ressort une fois par colonne qu'il couvrait, et chaque ligne de section aussi. Rien n'est perdu, mais n'attendez pas du Markdown qu'il reproduise la fusion visuelle. Et Docling lit les formules, pas leurs résultats — un tableur écrit par un script et jamais ouvert dans Excel se convertit avec tous les totaux vides. C'est pour cette raison que `budget.xlsx` stocke les valeurs calculées.

## À retenir {#key-takeaways}

**docling-convert en bref**

* **Le GPU est optionnel** — `--device auto` retombe proprement sur le CPU si le passthrough n'est pas configuré

* **Un package, cinq formats** — `pip install docling` tire `docling-slim[standard]`, qui couvre PDF, DOCX, PPTX, XLSX et HTML

* **Les modèles sont intégrés** — téléchargés au build, donc un container non privilégié en lecture seule n'a jamais rien à récupérer

* **Écrit des fichiers, pas sur stdout** — `docling-convert file.pdf` produit `file.md` directement, pas de redirection `>`

* **Lent volontairement** — `--pipeline vlm` garde paragraphes et titres intacts ; ajoutez `--pipeline standard` quand la vitesse compte plus

* **Seuls les PDF ont besoin des modèles** — `.docx`/`.xlsx`/`.pptx`/`.html` passent par un simple parser, sans GPU ni modèle

## Conclusion {#conclusion}

Entre celui-ci et [Markitdown](/blog/markitdown), j'ai maintenant deux images Docker qui transforment des documents bureautiques en Markdown sans qu'un seul octet ne quitte ma machine — une légère que j'utilise par réflexe, et celle-ci, plus lourde, pour quand la structure d'un PDF mérite qu'on y passe vingt secondes et un GPU. Et comme les deux produisent du Markdown brut, l'une comme l'autre s'intègre directement dans le genre de pipeline que je décrivais dans [Discutez avec vos documents grâce à AnythingLLM](/blog/anythingllm-chat-with-your-docs) — ou dans le prochain article de la série « Ollama au quotidien », où ce Markdown devient l'entrée d'un pipeline local de traduction et de résumé pour des documents qui n'ont jamais eu vocation à toucher le cloud.
