Skip to content

Commit deb247a

Browse files
artizclaude
andcommitted
pdf: VLM pipeline — remote OpenAI-compatible vision endpoint (#77)
docling's VlmPipeline in its remote (ApiVlmOptions) form: render each PDF page via pdfium, POST it (base64 PNG data URI) with docling's "Convert this page to docling." prompt to any OpenAI-compatible chat/completions endpoint (LM Studio / Ollama / vLLM / hosted), and parse the returned markup with the existing tolerant DocLang reader — per-page fragments are normalized (fence stripping, <doclang>/<doctag> root unwrapping) and stitched under one root, so the whole document goes through backend::doclang exactly like a .dclg file. Image inputs are sent as-is (they are their own page). No model code compiled in; local ONNX inference of a docling VLM stays a later enhancement. New `vlm` cargo feature on the docling crate (in defaults — it rides the ureq + pdf machinery the default build already carries; the wasm/ no-default builds are untouched). docling::vlm::{VlmOptions, convert_vlm}; endpoint accepts a /v1 base or a full …/chat/completions URL; DOCLING_RS_VLM_{ENDPOINT,MODEL,PROMPT,API_KEY} env fallbacks. Transport errors and 408/429/5xx retry 4 attempts with 2s/4s/8s backoff; any page that still fails fails the conversion — no silently dropped pages. temperature 0, max_tokens 8192. HTTP goes over ureq, the crate's existing blocking client (the issue sketch said reqwest, but the converter is synchronous and fetch-images already pins ureq — one HTTP stack, no async runtime). CLI: --pipeline standard|vlm, --vlm-endpoint URL, --vlm-model NAME. --pages composes (only the window renders and ships); --to md|json|dclx| chunks and --strict work via the shared buffered-output tail (factored out as output_document). Verified end to end against a local mock server: two-page conversion, per-page requests, markdown out; a missing endpoint errors with the flag/env hint. Tests: response-normalization unit tests; a mock OpenAI server e2e that renders two real pages (skips cleanly without pdfium), asserts the wire shape (path, model, prompt, data URI) and stitches differently-wrapped answers; an image-input leg that runs everywhere; a non-visual-format rejection. Docs: README "VLM pipeline (remote endpoint)" section — explicitly infrastructure, not a conformance claim: corpus comparison against docling's VLM output still needs a live endpoint — MIGRATION.md §5 + at-a-glance row. Refs #77 Signed-off-by: artiz <artem.kustikov@gmail.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EY5KAiquN4YpVf2PXEQkVT
1 parent d0cab7f commit deb247a

8 files changed

Lines changed: 604 additions & 6 deletions

File tree

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,34 @@ by a wide margin, but a scanned/image-only PDF (no embedded text layer) comes
376376
back empty rather than erroring, so a caller can detect that and re-convert
377377
without the flag.
378378

379+
### VLM pipeline (remote endpoint)
380+
381+
`--pipeline vlm` (issue #77) replaces the whole discriminative ML stack with a
382+
Vision Language Model: each PDF page is rendered (pdfium) and sent to any
383+
**OpenAI-compatible** vision endpoint — LM Studio, Ollama, vLLM, or a hosted
384+
service — with docling's page-conversion prompt; the returned DocLang markup
385+
is parsed by the same reader that `.dclg`/`.dclx` inputs use. An image input
386+
is sent as-is (it is its own page). No ONNX models load at all; local
387+
in-process VLM inference is a possible later enhancement.
388+
389+
```bash
390+
docling-rs --pipeline vlm \
391+
--vlm-endpoint http://localhost:11434/v1 \
392+
--vlm-model granite-docling \
393+
paper.pdf
394+
```
395+
396+
`--vlm-endpoint` takes the server's `/v1` base or the full
397+
`…/chat/completions` URL; `DOCLING_RS_VLM_ENDPOINT` / `DOCLING_RS_VLM_MODEL` /
398+
`DOCLING_RS_VLM_PROMPT` / `DOCLING_RS_VLM_API_KEY` (Bearer token) are the env
399+
equivalents. `--pages A-B` composes (only the window's pages are rendered and
400+
sent), and `--to md|json|dclx|chunks` plus `--strict` work as usual. Transient
401+
endpoint failures (timeouts, 408/429, 5xx) retry with exponential backoff;
402+
a page that still fails fails the conversion — no silently dropped pages.
403+
Output quality is entirely the model's: conversion of the PDF corpus against
404+
docling's own VLM pipeline output hasn't been measured yet (it needs a live
405+
endpoint), so treat this as infrastructure, not a conformance claim.
406+
379407
### Headless-browser HTML pre-render (optional)
380408

381409
Almost everything in the HTML backend is pure Rust, but one thing a static

crates/docling-cli/src/main.rs

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
//! The docling.rs counterpart of `docling.cli.main`; `docling-rs serve`
44
//! (with `--features serve`) starts the HTTP conversion API.
55
//!
6-
//! Usage: docling-rs [--strict] [--to md|json] [--pages A-B] [--images MODE] [--fetch-images] [--no-stream] [--no-table-former] [--no-ocr] [--ocr-lang en|ch] [--asr-model PRESET] [--video-frames N] [--use-web-browser] [--enrich-picture-classes] [--enrich-code] [--enrich-formula] <input-file>
6+
//! Usage: docling-rs [--strict] [--to md|json] [--pages A-B] [--images MODE] [--fetch-images] [--no-stream] [--no-table-former] [--no-ocr] [--ocr-lang en|ch] [--pipeline standard|vlm] [--vlm-endpoint URL] [--vlm-model NAME] [--asr-model PRESET] [--video-frames N] [--use-web-browser] [--enrich-picture-classes] [--enrich-code] [--enrich-formula] <input-file>
77
//! --to md|json output format (default: md). `json` emits docling-core's
88
//! native DoclingDocument JSON (export_to_dict).
99
//! --pages A-B convert only PDF pages A through B (1-based, inclusive;
@@ -95,6 +95,9 @@ fn main() -> ExitCode {
9595
let mut bench_warm: Option<usize> = None;
9696
let mut pages: Option<(usize, usize)> = None;
9797
let mut ocr_lang: Option<String> = None;
98+
let mut pipeline: Option<String> = None;
99+
let mut vlm_endpoint: Option<String> = None;
100+
let mut vlm_model: Option<String> = None;
98101
let mut path: Option<String> = None;
99102
let mut args = std::env::args().skip(1);
100103
while let Some(arg) = args.next() {
@@ -145,6 +148,22 @@ fn main() -> ExitCode {
145148
return ExitCode::from(2);
146149
}
147150
},
151+
// Pipeline selection (#77): `standard` (default, the ML stack) or
152+
// `vlm` — render pages and convert them through a remote
153+
// OpenAI-compatible vision endpoint returning DocLang.
154+
"--pipeline" => match args.next() {
155+
Some(v) if matches!(v.trim(), "standard" | "vlm") => pipeline = Some(v),
156+
Some(v) => {
157+
eprintln!("error: --pipeline {v:?} is not standard|vlm");
158+
return ExitCode::from(2);
159+
}
160+
None => {
161+
eprintln!("error: --pipeline needs a value (standard|vlm)");
162+
return ExitCode::from(2);
163+
}
164+
},
165+
"--vlm-endpoint" => vlm_endpoint = args.next(),
166+
"--vlm-model" => vlm_model = args.next(),
148167
// Hidden benchmarking aid: load the PDF/image pipeline once, then time
149168
// N warm conversions (models already loaded), printing the avg seconds
150169
// per conversion to stdout. This is the startup-excluded counterpart to
@@ -213,6 +232,29 @@ fn main() -> ExitCode {
213232
};
214233
}
215234

235+
// #77: the remote-VLM pipeline replaces the whole ML stack — convert,
236+
// then fall through to the regular output selection (md/json/dclx/chunks
237+
// all work; there is no page-streaming, the endpoint is the bottleneck).
238+
if pipeline.as_deref() == Some("vlm") {
239+
let mut opts = match docling::vlm::VlmOptions::resolve(vlm_endpoint, vlm_model) {
240+
Ok(o) => o,
241+
Err(e) => {
242+
eprintln!("error: {e}");
243+
return ExitCode::from(2);
244+
}
245+
};
246+
opts.page_range = pages;
247+
let mut document = match docling::vlm::convert_vlm(&source, &opts) {
248+
Ok(doc) => doc,
249+
Err(e) => {
250+
eprintln!("error: {e}");
251+
return ExitCode::FAILURE;
252+
}
253+
};
254+
document.strict_markdown = strict;
255+
return output_document(document, &to, image_mode, &path);
256+
}
257+
216258
let mut converter = DocumentConverter::new()
217259
.strict(strict)
218260
.asr_model(asr_model.clone())
@@ -281,7 +323,17 @@ fn main() -> ExitCode {
281323
return ExitCode::FAILURE;
282324
}
283325
};
326+
output_document(document, &to, image_mode, &path)
327+
}
284328

329+
/// The buffered output tail shared by the standard (non-streaming) and VLM
330+
/// paths: `--to` selection, image sidecars, exit code.
331+
fn output_document(
332+
document: docling::DoclingDocument,
333+
to: &str,
334+
image_mode: ImageMode,
335+
path: &str,
336+
) -> ExitCode {
285337
if to == "json" {
286338
println!("{}", document.export_to_json());
287339
return ExitCode::SUCCESS;

crates/docling/Cargo.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ exclude = ["tests", "benches"]
2020
# client are feature-sliced so `--no-default-features` leaves the pure-Rust
2121
# declarative converters (DOCX/HTML/MD/XLSX/PPTX/… → md/json) — a set that
2222
# compiles for wasm32-unknown-unknown (issue #79, see crates/docling-wasm).
23-
default = ["pdf", "asr", "fetch-images"]
23+
default = ["pdf", "asr", "fetch-images", "vlm"]
2424
# PDF/image/METS-GBS ML pipeline (pdfium + ONNX Runtime via docling-pdf).
2525
pdf = ["dep:docling-pdf", "docling-pdf/ml"]
2626
# Text-layer-only PDF conversion (docling-pdf's pure-Rust parser, no pdfium/
@@ -30,6 +30,11 @@ pdf-text = ["dep:docling-pdf"]
3030
asr = ["dep:docling-asr"]
3131
# Blocking HTTP fetch of remote <img src> images (DocumentConverter::fetch_images).
3232
fetch-images = ["dep:ureq", "dep:url"]
33+
# Remote VLM pipeline (#77): render PDF pages via pdfium and convert them
34+
# through an OpenAI-compatible vision endpoint (LM Studio / Ollama / vLLM /
35+
# hosted), parsing the returned DocLang with the existing reader. Rides on the
36+
# crate's existing blocking HTTP client; no model code compiled in.
37+
vlm = ["pdf", "dep:ureq"]
3338
# Optional headless-browser HTML pre-render (the `--use-web-browser` flag /
3439
# `DocumentConverter::use_web_browser`). Off by default so the standard build
3540
# stays pure-Rust with no browser/runtime dependency; enable with

crates/docling/src/backend/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ mod cfb;
2929
mod csv;
3030
mod deepseek;
3131
mod doc;
32-
mod doclang;
32+
pub(crate) mod doclang;
3333
mod docling_json;
3434
mod docx;
3535
mod email;

crates/docling/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ mod result;
3232
mod source;
3333
#[cfg(feature = "pdf")]
3434
mod stream;
35+
#[cfg(feature = "vlm")]
36+
pub mod vlm;
3537

3638
pub mod backend;
3739
#[cfg(feature = "asr")]

0 commit comments

Comments
 (0)