Skip to main content

docling/
options.rs

1//! The one set of conversion options every surface speaks (#577).
2//!
3//! [`ConvertOptions`] is the serializable, surface-neutral form of what
4//! configures a [`DocumentConverter`]: every knob of its builder plus the
5//! pipeline selection (`standard` | `vlm`) and the `vlm_*` settings. The CLI
6//! fills it from flags, docling-serve deserializes it from the query string /
7//! JSON body / multipart text parts, the C ABI and the wasm module take it as
8//! one JSON object, the Python and Node bindings map their keyword arguments
9//! into it — and all of them then go through the same two methods:
10//! [`ConvertOptions::validate`] (one set of rejection rules, one set of
11//! messages) and [`ConvertOptions::apply`] (one mapping onto the builder).
12//! Before this module each surface re-declared the fields, re-implemented
13//! the checks and re-mapped the names, so a new option meant edits in a dozen
14//! files and the surfaces drifted (the C ABI had no `list_attachments`, the
15//! Node bindings never rejected `vlmMaxTokens: 0`, …).
16//!
17//! Every field is an `Option`: `None` means "not given", and the engine's
18//! own default — defined once, in [`DocumentConverter::default`] — applies.
19//! That is what lets docling-serve layer its three option sources
20//! ([`merge_options`]: a later layer's `Some` wins, its `None` keeps the
21//! earlier value) and what lets a binding start from a pre-configured base
22//! converter and apply only what the caller said.
23//!
24//! The field names are the wire names: docling-serve's request options, the
25//! C ABI's and the wasm module's JSON keys, the CLI flags with `_` → `-`, the
26//! Node options in camelCase (napi's rename), the Python keyword arguments.
27//! The few places a surface spells one differently are recorded in
28//! [`OPTIONS`], the table the inventory test
29//! (`crates/docling/tests/options_inventory.rs`) checks every surface's
30//! documentation against — a field added here without its row, flag, kwarg
31//! or OpenAPI entry fails that test rather than shipping unreachable.
32//!
33//! Not in here: what to *emit* (`to`, the image mode, the Pandoc API version,
34//! the chunker settings — [`crate::chunks::ChunkOptions`]) and where. Those
35//! depend on what the surface can do with the result (serve streams, the
36//! browser cannot write files, the CLI writes several formats at once), so
37//! each surface keeps its own output options next to this struct.
38
39use std::fmt;
40
41use serde::de::DeserializeOwned;
42use serde::{Deserialize, Serialize};
43
44use docling_core::{CustomPattern, ImageRedaction, PiiKind, RedactionOptions, Replacement};
45
46use crate::DocumentConverter;
47
48/// Conversion options as every surface accepts them — see the module docs.
49/// `None` everywhere is the engine's defaults.
50#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
51#[serde(default)]
52pub struct ConvertOptions {
53    // --- output shaping carried on the document -----------------------------
54    /// Cleaner, more conformant Markdown ([`DocumentConverter::strict`]).
55    pub strict: Option<bool>,
56    /// Compact `| a | b |` Markdown tables instead of width padding (#271).
57    pub compact_tables: Option<bool>,
58    /// Text inserted between pages in the Markdown export (docling-core's
59    /// `MarkdownParams.page_break_placeholder`); docling-serve's historical
60    /// spelling `md_page_break_placeholder` is accepted as an alias.
61    #[serde(alias = "md_page_break_placeholder")]
62    pub page_break_placeholder: Option<String>,
63
64    // --- declarative formats -------------------------------------------------
65    /// Resolve external `<img src>` for HTML/EPUB/MHTML/JATS (network access)
66    /// — the pre-#646 switch: `true` is `image_sources=remote`, `false` is
67    /// `none`; an explicit `image_sources` wins.
68    pub fetch_images: Option<bool>,
69    /// Which image references resolve (#646): `none` (default — every picture
70    /// a placeholder) | `embedded` (`data:` URIs and parts of the same
71    /// container: EPUB/MHTML entries, email `cid:` attachments; no
72    /// filesystem or network) | `local` (plus files under the source file's
73    /// directory) | `remote` (plus `http(s)` fetches). HTML, EPUB, MHTML,
74    /// JATS, AsciiDoc, ODF, Markdown and email bodies.
75    pub image_sources: Option<String>,
76    /// Hosts a `remote` image fetch may reach (#646): exact names or
77    /// `*.suffix` wildcards, a list or a comma-separated string; unset =
78    /// any host. A redirect is held to the same list.
79    #[serde(default, deserialize_with = "de_string_list")]
80    pub image_hosts: Option<Vec<String>>,
81    /// Largest image that resolves, in bytes (#646; 32 MiB,
82    /// `DOCLING_RS_MAX_IMAGE_BYTES`).
83    pub max_image_bytes: Option<u64>,
84    /// How many images resolve per document; the rest stay placeholders
85    /// (#646; unlimited, `DOCLING_RS_MAX_IMAGES`).
86    pub max_images: Option<usize>,
87    /// Total resolved image bytes per document, in MiB (#646; unlimited,
88    /// `DOCLING_RS_MAX_IMAGE_TOTAL_MB`).
89    pub max_image_total_mb: Option<u64>,
90    /// Smallest image that resolves, in bytes — skips spacer / tracking
91    /// pixels (#646; 0, `DOCLING_RS_MIN_IMAGE_BYTES`).
92    pub min_image_bytes: Option<u64>,
93    /// Email (.eml/.msg): append an Attachments section (#251).
94    pub list_attachments: Option<bool>,
95    /// Omit empty cells from sparse XLSX/XLS table grids (#271).
96    pub skip_empty_cells: Option<bool>,
97    /// EBCDIC copybook layout (#252): inline JSON or a file path.
98    pub ebcdic_layout: Option<String>,
99    /// Character encoding of text inputs (a WHATWG label); unset = detect.
100    pub encoding: Option<String>,
101    /// Pre-render HTML with a headless browser (the `web-browser` feature).
102    pub use_web_browser: Option<bool>,
103    /// XBRL: the directory the instance's taxonomy is read from.
104    pub xbrl_taxonomy: Option<String>,
105
106    // --- audio / video -------------------------------------------------------
107    /// ASR preset (`whisper_*`, `parakeet_tdt_0.6b_v3`); unset = Whisper tiny.
108    pub asr_model: Option<String>,
109    /// Transcription language (a Whisper code) or `auto`.
110    pub asr_lang: Option<String>,
111    /// Max frames sampled from a video (0 = transcript only); unset =
112    /// [`crate::DEFAULT_VIDEO_FRAMES`]. `"all"` — or any count at or above
113    /// `u32::MAX` — is [`crate::ALL_VIDEO_FRAMES`]: every distinct cut (#647).
114    #[serde(deserialize_with = "de_video_frames")]
115    pub video_frames: Option<usize>,
116    /// ffmpeg's scene score (0–1) a video frame must exceed to be a cut
117    /// (#647); unset = 0.27 (`DOCLING_RS_VIDEO_SCENE_THRESHOLD`).
118    pub video_scene_threshold: Option<f32>,
119    /// Cap on a sampled video frame's longer side in px, applied inside
120    /// ffmpeg (#647); 0 / unset = the source resolution
121    /// (`DOCLING_RS_VIDEO_FRAME_MAX_SIDE`).
122    pub video_frame_max_side: Option<u32>,
123    /// Hamming distance (0–64) of the difference hash under which a sampled
124    /// frame is a duplicate of a kept one and dropped (#647); unset = keep
125    /// every frame (`DOCLING_RS_VIDEO_FRAME_DEDUPE`).
126    pub video_frame_dedupe: Option<u32>,
127
128    // --- PDF / image pipeline ------------------------------------------------
129    /// PDF page window, `"A-B"` or a single `"N"` (1-based inclusive, #80).
130    pub pages: Option<String>,
131    /// Per-document budget in seconds for the PDF pipeline (#497).
132    pub document_timeout: Option<f64>,
133    /// Skip the whole ML stack: text layer only (#611; `no_ocr` before 2.0).
134    pub text_layer_only: Option<bool>,
135    /// Keep layout + TableFormer, never run OCR — docling's `do_ocr=False`,
136    /// its CLI's `--no-ocr` (#611).
137    pub no_ocr: Option<bool>,
138    /// `no_ocr` under its pre-2.0 name (#244), still read: either one set
139    /// skips OCR ([`Self::ocr_disabled`]). A field of its own rather than a
140    /// serde alias, so a body that sends both spellings (a pre-2.0 form)
141    /// is not a duplicate-field error.
142    pub skip_ocr: Option<bool>,
143    /// The password of an encrypted PDF or Office document (#611, #625).
144    /// `pdf_password`, docling's PDF-only name (`--pdf-password`), is read
145    /// as an alias.
146    #[serde(alias = "pdf_password")]
147    pub password: Option<String>,
148    /// OCR every page, discarding the text layer.
149    pub force_full_page_ocr: Option<bool>,
150    /// Skip TableFormer (geometric tables instead).
151    pub no_table_former: Option<bool>,
152    /// Disable the text-panel heuristic (#173).
153    pub no_text_panels: Option<bool>,
154    /// Infer heading levels after assembly (#302).
155    pub heading_hierarchy: Option<bool>,
156    /// `ppocr` (default) | `tesseract` (#460).
157    pub ocr_engine: Option<String>,
158    /// OCR language: `en` | `ch` or a BCP-47 tag under PP-OCR, tessdata stems
159    /// or BCP-47 tags under Tesseract.
160    pub ocr_lang: Option<String>,
161    /// docling's `OcrMode` (#254): `default` | `full_page` | `layout_regions`
162    /// | `pdf_aware_layout_regions`.
163    pub ocr_mode: Option<String>,
164    /// OCR input scale in px per PDF point (#254); unset = the 2.0 render.
165    pub ocr_scale: Option<f32>,
166    /// Picture-crop / page-image scale in px per point, 0.1–4.0 (#520).
167    pub images_scale: Option<f32>,
168    /// Keep each page's render as the JSON page image (docling's
169    /// `generate_page_images`, #520 — accepted as an alias).
170    #[serde(alias = "generate_page_images")]
171    pub page_images: Option<bool>,
172    /// Enrichment: classify pictures with DocumentFigureClassifier (#423).
173    pub do_picture_classification: Option<bool>,
174    /// Enrichment: rewrite code blocks with CodeFormulaV2 (#423).
175    pub do_code_enrichment: Option<bool>,
176    /// Enrichment: decode display formulas to LaTeX with CodeFormulaV2 (#423).
177    pub do_formula_enrichment: Option<bool>,
178    /// Enrichment: OCR the embedded pictures of non-PDF documents (#645);
179    /// the text becomes the picture's description annotation.
180    pub do_picture_ocr: Option<bool>,
181    /// Comma-separated DocumentFigureClassifier labels a picture must be
182    /// classified as to be OCR'd (#645); unset/empty = every picture.
183    pub picture_ocr_classes: Option<String>,
184    /// Smallest side in px a picture must have to be OCR'd (#645; default
185    /// 32, `DOCLING_RS_PICTURE_OCR_MIN_SIDE`).
186    pub picture_ocr_min_side: Option<u32>,
187    /// Keep the embedded image bytes on the pictures (#645); `false` drops
188    /// them after the enrichment pass. Default `true`.
189    pub keep_picture_images: Option<bool>,
190
191    // --- PII redaction (#621) ---------------------------------------------
192    /// Redact personal data from the converted document before any export.
193    pub redact_pii: Option<bool>,
194    /// What a span becomes: `label` (default) | `pseudonym` | `fixed:<text>`.
195    pub redact_mode: Option<String>,
196    /// Comma-separated kinds to redact (`email,phone,credit_card,iban,
197    /// ip_address,url_credentials,national_id,person,organization,
198    /// location,address`); unset = every kind.
199    pub redact_kinds: Option<String>,
200    /// Extra patterns, `NAME=REGEX` entries separated by newlines (the CLI
201    /// flag repeats); the match (or capture group 1) is redacted as `[NAME]`.
202    pub redact_pattern: Option<String>,
203    /// Embedded images and page renders: `drop` (default) | `box_out` | `keep`.
204    pub redact_images: Option<String>,
205
206    // --- pipeline selection --------------------------------------------------
207    /// `standard` (default) | `vlm` (#77): the remote vision model instead of
208    /// the local ML stack. The `vlm_*` options are inert under `standard`.
209    pub pipeline: Option<String>,
210    /// VLM endpoint; falls back to `DOCLING_RS_VLM_ENDPOINT`.
211    pub vlm_endpoint: Option<String>,
212    /// VLM model name; falls back to `DOCLING_RS_VLM_MODEL`.
213    pub vlm_model: Option<String>,
214    /// Bearer token for the endpoint; falls back to `DOCLING_RS_VLM_API_KEY`.
215    pub vlm_api_key: Option<String>,
216    /// Per-page instruction; falls back to `DOCLING_RS_VLM_PROMPT`.
217    pub vlm_prompt: Option<String>,
218    /// `max_tokens` per completion (default 8192); 0 is rejected.
219    pub vlm_max_tokens: Option<usize>,
220}
221
222/// A rejected option: which field, and why. The message names the field in
223/// its wire spelling (`ocr_scale must be a positive number, got 0`); the CLI
224/// prints it through [`OptionsError::cli_message`], which swaps in the flag.
225#[derive(Debug, Clone, PartialEq, Eq)]
226pub struct OptionsError {
227    /// The wire name of the offending option — a [`ConvertOptions`] field.
228    pub field: &'static str,
229    /// Why it was rejected; names the field.
230    pub message: String,
231}
232
233impl OptionsError {
234    fn new(field: &'static str, message: impl Into<String>) -> Self {
235        Self {
236            field,
237            message: message.into(),
238        }
239    }
240
241    /// The message with the field spelled as its CLI flag (`--ocr-scale must
242    /// be a positive number, got 0`).
243    pub fn cli_message(&self) -> String {
244        let flag = cli_flag(self.field);
245        if self.message.contains(self.field) {
246            self.message.replacen(self.field, &flag, 1)
247        } else {
248            format!("{flag}: {}", self.message)
249        }
250    }
251}
252
253impl fmt::Display for OptionsError {
254    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
255        f.write_str(&self.message)
256    }
257}
258
259impl std::error::Error for OptionsError {}
260
261/// Which conversion pipeline an option set selects.
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263pub enum PipelineKind {
264    /// The local ML stack (layout, TableFormer, OCR) — the default.
265    Standard,
266    /// A remote OpenAI-compatible vision model (#77).
267    Vlm,
268}
269
270/// How one option is spelled on each surface, where it differs from the wire
271/// name — the central documentation the inventory test holds every surface
272/// to. One row per [`ConvertOptions`] field (the test checks both ways).
273#[derive(Debug, Clone, Copy)]
274pub struct OptionInfo {
275    /// The field / wire name.
276    pub name: &'static str,
277    /// Other wire spellings accepted on deserialization (`serde(alias)`).
278    pub aliases: &'static [&'static str],
279    /// The CLI flag; `None` = the default `--name-with-dashes`.
280    pub cli: Option<&'static str>,
281    /// The Python `DocumentConverter(...)` keyword argument; `Some("")` when
282    /// the Python surface has no such argument (the reason is in the row's
283    /// comment); `None` = the field name itself.
284    pub python: Option<&'static str>,
285    /// The Node option (napi spells the field in camelCase); `Some("")` when
286    /// the Node surface has no such option (reason in the row's comment);
287    /// `None` = the camelCase of the field name.
288    pub node: Option<&'static str>,
289}
290
291const fn row(name: &'static str) -> OptionInfo {
292    OptionInfo {
293        name,
294        aliases: &[],
295        cli: None,
296        python: None,
297        node: None,
298    }
299}
300
301/// The per-surface spelling table — see [`OptionInfo`].
302pub const OPTIONS: &[OptionInfo] = &[
303    // Python picks Markdown strictness at export time (`export_to_markdown`
304    // reads `document.strict_markdown`), so the converter takes no kwarg.
305    OptionInfo {
306        python: Some(""),
307        ..row("strict")
308    },
309    row("compact_tables"),
310    // Python: `export_to_markdown(page_break_placeholder=…)`, docling's own
311    // signature, not a converter kwarg.
312    OptionInfo {
313        aliases: &["md_page_break_placeholder"],
314        python: Some(""),
315        ..row("page_break_placeholder")
316    },
317    row("fetch_images"),
318    row("image_sources"),
319    row("image_hosts"),
320    row("max_image_bytes"),
321    row("max_images"),
322    row("max_image_total_mb"),
323    row("min_image_bytes"),
324    row("list_attachments"),
325    row("skip_empty_cells"),
326    row("ebcdic_layout"),
327    row("encoding"),
328    // The npm addon is not built with the `web-browser` feature, so the
329    // option would only ever error there.
330    OptionInfo {
331        node: Some(""),
332        ..row("use_web_browser")
333    },
334    row("xbrl_taxonomy"),
335    row("asr_model"),
336    row("asr_lang"),
337    row("video_frames"),
338    row("video_scene_threshold"),
339    row("video_frame_max_side"),
340    row("video_frame_dedupe"),
341    // Python takes docling's `page_range=(first, last)` tuple.
342    OptionInfo {
343        python: Some("page_range"),
344        ..row("pages")
345    },
346    row("document_timeout"),
347    // Python keeps docling's positive spellings: `do_ocr`,
348    // `do_table_structure` — and the docling.rs-only `text_layer_only`.
349    // `no_ocr` is docling's `--no-ocr` since 2.0 (#611); `skip_ocr` is its
350    // pre-2.0 name, still read (the CLI's `--skip-ocr` too) — Python has
351    // docling's `do_ocr` for both.
352    row("text_layer_only"),
353    OptionInfo {
354        python: Some("do_ocr"),
355        ..row("no_ocr")
356    },
357    OptionInfo {
358        python: Some(""),
359        ..row("skip_ocr")
360    },
361    // One password for PDFs and Office documents (#625); docling's
362    // PDF-only `pdf_password` stays readable on every surface.
363    OptionInfo {
364        aliases: &["pdf_password"],
365        ..row("password")
366    },
367    row("force_full_page_ocr"),
368    OptionInfo {
369        python: Some("do_table_structure"),
370        ..row("no_table_former")
371    },
372    row("no_text_panels"),
373    row("heading_hierarchy"),
374    row("ocr_engine"),
375    row("ocr_lang"),
376    row("ocr_mode"),
377    row("ocr_scale"),
378    row("images_scale"),
379    OptionInfo {
380        aliases: &["generate_page_images"],
381        python: Some("generate_page_images"),
382        ..row("page_images")
383    },
384    OptionInfo {
385        cli: Some("--enrich-picture-classes"),
386        ..row("do_picture_classification")
387    },
388    OptionInfo {
389        cli: Some("--enrich-code"),
390        ..row("do_code_enrichment")
391    },
392    OptionInfo {
393        cli: Some("--enrich-formula"),
394        ..row("do_formula_enrichment")
395    },
396    OptionInfo {
397        cli: Some("--picture-ocr"),
398        ..row("do_picture_ocr")
399    },
400    row("picture_ocr_classes"),
401    row("picture_ocr_min_side"),
402    OptionInfo {
403        cli: Some("--no-picture-images"),
404        ..row("keep_picture_images")
405    },
406    row("redact_pii"),
407    row("redact_mode"),
408    row("redact_kinds"),
409    row("redact_pattern"),
410    row("redact_images"),
411    row("pipeline"),
412    row("vlm_endpoint"),
413    row("vlm_model"),
414    row("vlm_api_key"),
415    row("vlm_prompt"),
416    row("vlm_max_tokens"),
417];
418
419/// The CLI flag for a [`ConvertOptions`] field: its [`OPTIONS`] row's `cli`,
420/// else `--` + the name with `_` → `-`.
421pub fn cli_flag(field: &str) -> String {
422    OPTIONS
423        .iter()
424        .find(|o| o.name == field)
425        .and_then(|o| o.cli)
426        .map(str::to_string)
427        .unwrap_or_else(|| format!("--{}", field.replace('_', "-")))
428}
429
430/// `over` on top of `base`: every field `over` sets (serializes non-null)
431/// replaces `base`'s, every one it leaves unset keeps `base`'s — for any
432/// all-`Option` options struct, which is why docling-serve's request struct
433/// (this one plus its output fields) merges its query / body / form layers
434/// through it without a per-field list.
435pub fn merge_options<T: Serialize + DeserializeOwned + Default>(over: T, base: T) -> T {
436    let mut merged = serde_json::to_value(base).unwrap_or(serde_json::Value::Null);
437    let over = serde_json::to_value(over).unwrap_or(serde_json::Value::Null);
438    if let (Some(into), Some(from)) = (merged.as_object_mut(), over.as_object()) {
439        for (key, value) in from {
440            if !value.is_null() {
441                into.insert(key.clone(), value.clone());
442            }
443        }
444    }
445    // A struct that serialized to an object deserializes from it; the
446    // fallback only guards a `T` that is not an object at all.
447    serde_json::from_value(merged).unwrap_or_default()
448}
449
450/// A list option on the wire: a JSON array, or one string the text surfaces
451/// (query, multipart) send — split on commas, trimmed, empties dropped.
452fn de_string_list<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<Vec<String>>, D::Error> {
453    #[derive(Deserialize)]
454    #[serde(untagged)]
455    enum Raw {
456        List(Vec<String>),
457        Text(String),
458    }
459    Ok(Option::<Raw>::deserialize(d)?.map(|raw| {
460        let items: Vec<String> = match raw {
461            Raw::List(v) => v,
462            Raw::Text(s) => s.split(',').map(str::to_string).collect(),
463        };
464        items
465            .into_iter()
466            .map(|s| s.trim().to_string())
467            .filter(|s| !s.is_empty())
468            .collect()
469    }))
470}
471
472/// `video_frames` on the wire (#647): an integer, or `"all"` (any case,
473/// also a digit string, which the text surfaces send) for
474/// [`crate::ALL_VIDEO_FRAMES`]; a count at or above `u32::MAX` reads as
475/// "all" too, so a 32-bit binding can ask for it.
476fn de_video_frames<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<usize>, D::Error> {
477    #[derive(Deserialize)]
478    #[serde(untagged)]
479    enum Raw {
480        Count(u64),
481        Text(String),
482    }
483    let raw = Option::<Raw>::deserialize(d)?;
484    let n = match raw {
485        None => return Ok(None),
486        Some(Raw::Count(n)) => n,
487        Some(Raw::Text(s)) => {
488            let s = s.trim();
489            if s.eq_ignore_ascii_case("all") {
490                return Ok(Some(crate::ALL_VIDEO_FRAMES));
491            }
492            s.parse::<u64>().map_err(|_| {
493                serde::de::Error::custom(format!(
494                    "video_frames: expected a non-negative integer or \"all\", got {s:?}"
495                ))
496            })?
497        }
498    };
499    Ok(Some(if n >= u32::MAX as u64 {
500        crate::ALL_VIDEO_FRAMES
501    } else {
502        n as usize
503    }))
504}
505
506impl ConvertOptions {
507    /// The image tier `image_sources` names (#646); `None` when unset (the
508    /// `fetch_images` alias, else the converter's default, decides).
509    pub fn image_sources(&self) -> Result<Option<crate::ImageSources>, OptionsError> {
510        self.image_sources
511            .as_deref()
512            .filter(|s| !s.trim().is_empty())
513            .map(|s| {
514                s.parse::<crate::ImageSources>()
515                    .map_err(|e| OptionsError::new("image_sources", e))
516            })
517            .transpose()
518    }
519
520    /// The wire names of every field, from the struct itself (an unset
521    /// option serializes as `null`), so a new field is listed without a
522    /// hand-kept list — the inventory test and the unknown-key checks of the
523    /// JSON surfaces read it.
524    pub fn field_names() -> Vec<String> {
525        match serde_json::to_value(Self::default()) {
526            Ok(serde_json::Value::Object(map)) => map.keys().cloned().collect(),
527            _ => Vec::new(),
528        }
529    }
530
531    /// The keys among `keys` that are neither a field, one of its aliases,
532    /// nor in `also_known` — what a JSON surface that rejects typos (the C
533    /// ABI, the wasm module) reports.
534    pub fn unknown_keys<'a>(
535        keys: impl IntoIterator<Item = &'a str>,
536        also_known: &[&str],
537    ) -> Vec<String> {
538        let fields = Self::field_names();
539        keys.into_iter()
540            .filter(|k| {
541                !fields.iter().any(|f| f == k)
542                    && !also_known.contains(k)
543                    && !OPTIONS.iter().any(|o| o.aliases.contains(k))
544            })
545            .map(str::to_string)
546            .collect()
547    }
548
549    /// `self` on top of `base` — see [`merge_options`].
550    pub fn merge_over(self, base: ConvertOptions) -> ConvertOptions {
551        merge_options(self, base)
552    }
553
554    /// Check every option that can be checked without the input document —
555    /// the one set of rules all surfaces apply, before any work starts:
556    /// `pages` spelling, a positive `document_timeout`, a positive
557    /// `ocr_scale`, `images_scale` in 0.1–4.0, a known `pipeline`, a positive
558    /// `vlm_max_tokens`, and (with the `pdf` feature, which has the engine
559    /// tables) a known `ocr_engine` / `ocr_mode`, an `ocr_lang` the
560    /// selected engine reads and `picture_ocr_classes` the classifier
561    /// predicts. `asr_lang`, `encoding` and `ebcdic_layout` are
562    /// checked against the model / codec / copybook when the conversion runs.
563    pub fn validate(&self) -> Result<(), OptionsError> {
564        self.page_range()?;
565        self.document_timeout()?;
566        if let Some(s) = self.ocr_scale {
567            if !(s.is_finite() && s > 0.0) {
568                return Err(OptionsError::new(
569                    "ocr_scale",
570                    format!("ocr_scale must be a positive number, got {s}"),
571                ));
572            }
573        }
574        if let Some(s) = self.images_scale {
575            if !(0.1..=4.0).contains(&s) {
576                return Err(OptionsError::new(
577                    "images_scale",
578                    format!("images_scale must be a number in 0.1-4.0, got {s}"),
579                ));
580            }
581        }
582        self.image_sources()?;
583        if self.max_image_bytes == Some(0) {
584            return Err(OptionsError::new(
585                "max_image_bytes",
586                "max_image_bytes must be a positive number of bytes, got 0",
587            ));
588        }
589        if let Some(t) = self.video_scene_threshold {
590            if !(t.is_finite() && (0.0..=1.0).contains(&t)) {
591                return Err(OptionsError::new(
592                    "video_scene_threshold",
593                    format!("video_scene_threshold must be a number in 0.0-1.0, got {t}"),
594                ));
595            }
596        }
597        if let Some(d) = self.video_frame_dedupe {
598            if d > 64 {
599                return Err(OptionsError::new(
600                    "video_frame_dedupe",
601                    format!("video_frame_dedupe must be a Hamming distance in 0-64, got {d}"),
602                ));
603            }
604        }
605        self.pipeline()?;
606        if self.vlm_max_tokens == Some(0) {
607            return Err(OptionsError::new(
608                "vlm_max_tokens",
609                "vlm_max_tokens must be a positive integer, got 0",
610            ));
611        }
612        #[cfg(feature = "pdf")]
613        {
614            self.ocr_engine()?;
615            self.ocr_mode()?;
616            self.ocr_lang()?;
617        }
618        self.picture_ocr_classes()?;
619        self.redaction()?;
620        Ok(())
621    }
622
623    /// The parsed `picture_ocr_classes` (#645): the comma-separated labels,
624    /// trimmed and lower-cased, empty when unset. With the `pdf` feature
625    /// (which has the classifier's label table) a label the
626    /// DocumentFigureClassifier never predicts is rejected — a typo would
627    /// otherwise silently skip every picture.
628    pub fn picture_ocr_classes(&self) -> Result<Vec<String>, OptionsError> {
629        let labels: Vec<String> = self
630            .picture_ocr_classes
631            .as_deref()
632            .unwrap_or("")
633            .split(',')
634            .map(|s| s.trim().to_ascii_lowercase())
635            .filter(|s| !s.is_empty())
636            .collect();
637        #[cfg(feature = "pdf")]
638        if let Some(bad) = labels
639            .iter()
640            .find(|l| !docling_pdf::enrich::PICTURE_CLASSES.contains(&l.as_str()))
641        {
642            return Err(OptionsError::new(
643                "picture_ocr_classes",
644                format!(
645                    "picture_ocr_classes: {bad:?} is not a DocumentFigureClassifier label ({})",
646                    docling_pdf::enrich::PICTURE_CLASSES.join(", ")
647                ),
648            ));
649        }
650        Ok(labels)
651    }
652
653    /// The parsed PII redaction request (#621): `None` unless `redact_pii`
654    /// is set; the mode, kinds, patterns and image mode checked — an
655    /// unknown spelling or an invalid regex is rejected here, naming the
656    /// field, rather than failing the conversion.
657    pub fn redaction(&self) -> Result<Option<RedactionOptions>, OptionsError> {
658        let mut opts = RedactionOptions::default();
659        if let Some(m) = self.redact_mode.as_deref().filter(|m| !m.trim().is_empty()) {
660            opts.replacement = Replacement::parse(m).ok_or_else(|| {
661                OptionsError::new(
662                    "redact_mode",
663                    format!(
664                        "redact_mode must be one of {} , got {m:?}",
665                        Replacement::ACCEPTED
666                    ),
667                )
668            })?;
669        }
670        if let Some(k) = self.redact_kinds.as_deref() {
671            for name in k.split(',').map(str::trim).filter(|s| !s.is_empty()) {
672                let kind = PiiKind::parse(name).ok_or_else(|| {
673                    OptionsError::new(
674                        "redact_kinds",
675                        format!(
676                            "redact_kinds: {name:?} is not a PII kind ({})",
677                            PiiKind::ACCEPTED
678                        ),
679                    )
680                })?;
681                if !opts.kinds.contains(&kind) {
682                    opts.kinds.push(kind);
683                }
684            }
685        }
686        if let Some(p) = self.redact_pattern.as_deref() {
687            opts.custom_patterns = p.lines().filter_map(CustomPattern::parse).collect();
688            // Compile now: the error names the field.
689            docling_core::redact::PatternDetector::new(&opts)
690                .map_err(|e| OptionsError::new("redact_pattern", e.0))?;
691        }
692        if let Some(i) = self
693            .redact_images
694            .as_deref()
695            .filter(|i| !i.trim().is_empty())
696        {
697            opts.images = ImageRedaction::parse(i).ok_or_else(|| {
698                OptionsError::new(
699                    "redact_images",
700                    format!(
701                        "redact_images must be one of {}, got {i:?}",
702                        ImageRedaction::ACCEPTED
703                    ),
704                )
705            })?;
706        }
707        Ok(self.redact_pii.unwrap_or(false).then_some(opts))
708    }
709
710    /// Validate, then set every given option on `base` — unset ones leave the
711    /// base's values (so a surface may pre-configure defaults of its own,
712    /// e.g. docling-serve's `--strict`). [`DocumentConverter::from_options`]
713    /// is this on a fresh converter.
714    pub fn apply(&self, mut c: DocumentConverter) -> Result<DocumentConverter, OptionsError> {
715        self.validate()?;
716        if let Some(v) = self.strict {
717            c = c.strict(v);
718        }
719        if let Some(v) = self.compact_tables {
720            c = c.compact_tables(v);
721        }
722        if self.page_break_placeholder.is_some() {
723            c = c.page_break_placeholder(self.page_break_placeholder.clone());
724        }
725        if let Some(v) = self.fetch_images {
726            c = c.fetch_images(v);
727        }
728        if let Some(sources) = self.image_sources()? {
729            c = c.image_sources(sources);
730        }
731        if let Some(hosts) = &self.image_hosts {
732            c = c.image_hosts(hosts.iter().cloned());
733        }
734        if let Some(v) = self.max_image_bytes {
735            c = c.max_image_bytes(v);
736        }
737        if let Some(v) = self.max_images {
738            c = c.max_images(v);
739        }
740        if let Some(v) = self.max_image_total_mb {
741            c = c.max_image_total_mb(v);
742        }
743        if let Some(v) = self.min_image_bytes {
744            c = c.min_image_bytes(v);
745        }
746        if let Some(v) = self.list_attachments {
747            c = c.list_attachments(v);
748        }
749        if let Some(v) = self.skip_empty_cells {
750            c = c.skip_empty_cells(v);
751        }
752        if self.ebcdic_layout.is_some() {
753            c = c.ebcdic_layout_opt(self.ebcdic_layout.clone());
754        }
755        if self.encoding.is_some() {
756            c = c.encoding(self.encoding.clone());
757        }
758        if let Some(v) = self.use_web_browser {
759            c = c.use_web_browser(v);
760        }
761        if let Some(dir) = &self.xbrl_taxonomy {
762            c = c.xbrl_taxonomy(dir);
763        }
764        if self.asr_model.is_some() {
765            c = c.asr_model(self.asr_model.clone());
766        }
767        if self.asr_lang.is_some() {
768            c = c.asr_lang(self.asr_lang.clone());
769        }
770        if let Some(n) = self.video_frames {
771            c = c.video_frames(n);
772        }
773        if let Some(t) = self.video_scene_threshold {
774            c = c.video_scene_threshold(t);
775        }
776        if let Some(px) = self.video_frame_max_side {
777            c = c.video_frame_max_side(px);
778        }
779        if let Some(d) = self.video_frame_dedupe {
780            c = c.video_frame_dedupe(Some(d));
781        }
782        if let Some((first, last)) = self.page_range()? {
783            c = c.page_range(first, last);
784        }
785        if self.document_timeout.is_some() {
786            c = c.document_timeout(self.document_timeout()?);
787        }
788        if let Some(v) = self.text_layer_only {
789            c = c.text_layer_only(v);
790        }
791        if self.no_ocr.is_some() || self.skip_ocr.is_some() {
792            c = c.no_ocr(self.ocr_disabled());
793        }
794        if self.password.is_some() {
795            c = c.password(self.password.clone());
796        }
797        if let Some(v) = self.force_full_page_ocr {
798            c = c.force_full_page_ocr(v);
799        }
800        if let Some(v) = self.no_table_former {
801            c = c.no_table_former(v);
802        }
803        if let Some(v) = self.no_text_panels {
804            c = c.no_text_panels(v);
805        }
806        if let Some(v) = self.heading_hierarchy {
807            c = c.heading_hierarchy(v);
808        }
809        if let Some(v) = &self.ocr_engine {
810            c = c.ocr_engine(v.clone());
811        }
812        if let Some(v) = &self.ocr_lang {
813            c = c.ocr_lang(v.clone());
814        }
815        if let Some(v) = &self.ocr_mode {
816            c = c.ocr_mode(v.clone());
817        }
818        if let Some(s) = self.ocr_scale {
819            c = c.ocr_scale(s);
820        }
821        if let Some(s) = self.images_scale {
822            c = c.images_scale(s);
823        }
824        if let Some(v) = self.page_images {
825            c = c.generate_page_images(v);
826        }
827        if let Some(v) = self.do_picture_classification {
828            c = c.do_picture_classification(v);
829        }
830        if let Some(v) = self.do_code_enrichment {
831            c = c.do_code_enrichment(v);
832        }
833        if let Some(v) = self.do_formula_enrichment {
834            c = c.do_formula_enrichment(v);
835        }
836        if let Some(v) = self.do_picture_ocr {
837            c = c.do_picture_ocr(v);
838        }
839        if self.picture_ocr_classes.is_some() {
840            c = c.picture_ocr_classes(self.picture_ocr_classes()?);
841        }
842        if let Some(v) = self.picture_ocr_min_side {
843            c = c.picture_ocr_min_side(v);
844        }
845        if let Some(v) = self.keep_picture_images {
846            c = c.keep_picture_images(v);
847        }
848        if self.redact_pii.is_some() {
849            c = c.redact_pii_opt(self.redaction()?);
850        }
851        Ok(c)
852    }
853
854    // --- typed readers, for the surfaces that drive a warm `Pipeline`
855    //     themselves and need the parsed values rather than the builder -----
856
857    /// `pages` parsed ([`crate::parse_page_range`]); `None` when unset.
858    pub fn page_range(&self) -> Result<Option<(usize, usize)>, OptionsError> {
859        self.pages
860            .as_deref()
861            .map(|p| {
862                crate::parse_page_range(p)
863                    .map_err(|e| OptionsError::new("pages", format!("pages: {e}")))
864            })
865            .transpose()
866    }
867
868    /// `document_timeout` as a duration; `None` when unset.
869    pub fn document_timeout(&self) -> Result<Option<std::time::Duration>, OptionsError> {
870        match self.document_timeout {
871            Some(s) if s.is_finite() && s > 0.0 => Ok(Some(std::time::Duration::from_secs_f64(s))),
872            Some(s) => Err(OptionsError::new(
873                "document_timeout",
874                format!("document_timeout must be a positive number of seconds, got {s}"),
875            )),
876            None => Ok(None),
877        }
878    }
879
880    /// The pipeline the options select.
881    pub fn pipeline(&self) -> Result<PipelineKind, OptionsError> {
882        match self.pipeline.as_deref().map(str::trim) {
883            None | Some("standard") => Ok(PipelineKind::Standard),
884            Some("vlm") => Ok(PipelineKind::Vlm),
885            Some(other) => Err(OptionsError::new(
886                "pipeline",
887                format!("unknown pipeline {other:?} (expected: standard, vlm)"),
888            )),
889        }
890    }
891
892    /// The enrichment passes (#423) — unset and `false` both mean off.
893    pub fn enrichments(&self) -> crate::EnrichmentOptions {
894        crate::EnrichmentOptions {
895            picture_classification: self.do_picture_classification.unwrap_or(false),
896            code: self.do_code_enrichment.unwrap_or(false),
897            formula: self.do_formula_enrichment.unwrap_or(false),
898        }
899    }
900
901    /// `ocr_engine` parsed; `None` when unset (the process default applies).
902    #[cfg(feature = "pdf")]
903    pub fn ocr_engine(&self) -> Result<Option<crate::OcrEngine>, OptionsError> {
904        self.ocr_engine
905            .as_deref()
906            .map(|v| {
907                crate::OcrEngine::parse(v).ok_or_else(|| {
908                    OptionsError::new(
909                        "ocr_engine",
910                        format!("ocr_engine {v:?} is not {}", crate::OcrEngine::ACCEPTED),
911                    )
912                })
913            })
914            .transpose()
915    }
916
917    /// The engine `ocr_lang` is read against: `ocr_engine`, else the process
918    /// default (`DOCLING_RS_OCR_ENGINE`, else PP-OCR).
919    #[cfg(feature = "pdf")]
920    pub fn effective_ocr_engine(&self) -> Result<crate::OcrEngine, OptionsError> {
921        Ok(self
922            .ocr_engine()?
923            .unwrap_or_else(crate::OcrEngine::from_env))
924    }
925
926    /// `ocr_lang` validated against the engine (#460) and, under PP-OCR, the
927    /// recognition model it selects; `None` when unset or under Tesseract
928    /// (whose language list is [`Self::tesseract_lang`]).
929    #[cfg(feature = "pdf")]
930    pub fn ocr_lang(&self) -> Result<Option<crate::OcrLang>, OptionsError> {
931        let Some(v) = self.ocr_lang.as_deref() else {
932            return Ok(None);
933        };
934        let engine = self.effective_ocr_engine()?;
935        engine.validate_lang(v).map_err(|e| {
936            OptionsError::new(
937                "ocr_lang",
938                if e.contains("ocr_lang") {
939                    e
940                } else {
941                    format!("ocr_lang: {e}")
942                },
943            )
944        })?;
945        Ok(match engine {
946            crate::OcrEngine::PpOcr => crate::OcrLang::parse(v),
947            crate::OcrEngine::Tesseract => None,
948        })
949    }
950
951    /// Tesseract's `-l` argument from `ocr_lang` (#460); `None` under PP-OCR
952    /// or without a language.
953    #[cfg(feature = "pdf")]
954    pub fn tesseract_lang(&self) -> Result<Option<String>, OptionsError> {
955        let Some(v) = self.ocr_lang.as_deref() else {
956            return Ok(None);
957        };
958        match self.effective_ocr_engine()? {
959            crate::OcrEngine::Tesseract => crate::tesseract_lang_arg(v)
960                .map(Some)
961                .map_err(|e| OptionsError::new("ocr_lang", e)),
962            crate::OcrEngine::PpOcr => Ok(None),
963        }
964    }
965
966    /// Whether OCR is off: `no_ocr` or its pre-2.0 name `skip_ocr` (#611).
967    /// What a surface that builds the PDF pipeline itself reads.
968    pub fn ocr_disabled(&self) -> bool {
969        self.no_ocr.unwrap_or(false) || self.skip_ocr.unwrap_or(false)
970    }
971
972    /// `ocr_mode` parsed (#254); `None` when unset.
973    #[cfg(feature = "pdf")]
974    pub fn ocr_mode(&self) -> Result<Option<crate::OcrMode>, OptionsError> {
975        self.ocr_mode
976            .as_deref()
977            .map(|v| {
978                crate::OcrMode::parse(v).ok_or_else(|| {
979                    OptionsError::new(
980                        "ocr_mode",
981                        format!(
982                            "ocr_mode {v:?} is not \
983                             default|full_page|layout_regions|pdf_aware_layout_regions"
984                        ),
985                    )
986                })
987            })
988            .transpose()
989    }
990
991    /// Picture-crop scale and page images for the pipeline (#519/#520).
992    #[cfg(feature = "pdf")]
993    pub fn image_output(&self) -> crate::ImageOutput {
994        crate::ImageOutput {
995            scale: self.images_scale,
996            page_images: self.page_images.unwrap_or(false),
997        }
998    }
999
1000    /// The remote-VLM configuration (#77) when `pipeline` is `vlm`, resolved
1001    /// against the `DOCLING_RS_VLM_*` environment the way every surface did
1002    /// on its own before: endpoint and model from the options else the env
1003    /// (blank counts as unset), the prompt and API key likewise, `pages`
1004    /// composing like it does with the ML pipeline. `None` under the standard
1005    /// pipeline — the `vlm_*` options are then ignored, not rejected (the
1006    /// bindings' long-standing contract).
1007    #[cfg(feature = "vlm")]
1008    pub fn vlm_options(&self) -> Result<Option<crate::vlm::VlmOptions>, OptionsError> {
1009        if self.pipeline()? != PipelineKind::Vlm {
1010            return Ok(None);
1011        }
1012        let set = |s: &Option<String>| s.clone().filter(|v| !v.trim().is_empty());
1013        let mut v = crate::vlm::VlmOptions::resolve(set(&self.vlm_endpoint), set(&self.vlm_model))
1014            .map_err(|e| {
1015                // The library's message names the CLI flags; the wire spelling
1016                // is this surface's (the CLI swaps its flags back in).
1017                let msg = match e {
1018                    crate::ConversionError::Parse(m) => m,
1019                    other => other.to_string(),
1020                }
1021                .replace("pass --vlm-endpoint", "set vlm_endpoint")
1022                .replace("pass --vlm-model", "set vlm_model");
1023                let field = if msg.contains("vlm_model") {
1024                    "vlm_model"
1025                } else {
1026                    "vlm_endpoint"
1027                };
1028                OptionsError::new(field, msg)
1029            })?;
1030        if let Some(p) = set(&self.vlm_prompt) {
1031            v.prompt = Some(p);
1032        }
1033        if let Some(k) = set(&self.vlm_api_key) {
1034            v.api_key = Some(k);
1035        }
1036        match self.vlm_max_tokens {
1037            // 0 would have every page come back empty and surface as a model
1038            // error — a message pointing at the model, not at the option.
1039            Some(0) => {
1040                return Err(OptionsError::new(
1041                    "vlm_max_tokens",
1042                    "vlm_max_tokens must be a positive integer, got 0",
1043                ))
1044            }
1045            Some(n) => v.max_tokens = n,
1046            None => {}
1047        }
1048        v.page_range = self.page_range()?;
1049        Ok(Some(v))
1050    }
1051}
1052
1053impl DocumentConverter {
1054    /// A converter configured from [`ConvertOptions`] — validated and applied
1055    /// onto the defaults ([`ConvertOptions::apply`] on [`Self::new`]).
1056    pub fn from_options(options: &ConvertOptions) -> Result<Self, OptionsError> {
1057        options.apply(Self::new())
1058    }
1059}
1060
1061#[cfg(test)]
1062mod tests {
1063    use super::*;
1064
1065    /// The spelling table and the struct describe the same fields, both ways
1066    /// — the guard that makes "add a field" also mean "add its row".
1067    #[test]
1068    fn options_table_matches_the_struct() {
1069        let fields = ConvertOptions::field_names();
1070        for o in OPTIONS {
1071            assert!(
1072                fields.iter().any(|f| f == o.name),
1073                "no field for row {}",
1074                o.name
1075            );
1076            for a in o.aliases {
1077                assert!(!fields.iter().any(|f| f == a), "alias {a} is also a field");
1078            }
1079        }
1080        for f in &fields {
1081            assert!(
1082                OPTIONS.iter().any(|o| o.name == f),
1083                "no OPTIONS row for field {f}"
1084            );
1085        }
1086        assert_eq!(cli_flag("ocr_scale"), "--ocr-scale");
1087        assert_eq!(cli_flag("do_code_enrichment"), "--enrich-code");
1088    }
1089
1090    #[test]
1091    fn aliases_deserialize_and_unknown_keys_are_reported() {
1092        let o: ConvertOptions = serde_json::from_str(
1093            r#"{"md_page_break_placeholder": "<!-- p -->", "generate_page_images": true}"#,
1094        )
1095        .unwrap();
1096        assert_eq!(o.page_break_placeholder.as_deref(), Some("<!-- p -->"));
1097        assert_eq!(o.page_images, Some(true));
1098        assert_eq!(
1099            ConvertOptions::unknown_keys(
1100                ["to", "strict", "md_page_break_placeholder", "strictness"],
1101                &["to"]
1102            ),
1103            vec!["strictness".to_string()]
1104        );
1105    }
1106
1107    /// #647: `video_frames` reads an integer, `"all"`, a digit string (the
1108    /// text surfaces) and a 32-bit-max count as the npm addon sends it;
1109    /// the two bounded tunables are range-checked.
1110    #[test]
1111    fn video_frames_spellings_and_video_ranges() {
1112        let parse = |json: &str| serde_json::from_str::<ConvertOptions>(json).unwrap();
1113        assert_eq!(parse(r#"{"video_frames": 3}"#).video_frames, Some(3));
1114        assert_eq!(parse(r#"{"video_frames": "12"}"#).video_frames, Some(12));
1115        assert_eq!(
1116            parse(r#"{"video_frames": "all"}"#).video_frames,
1117            Some(crate::ALL_VIDEO_FRAMES)
1118        );
1119        assert_eq!(
1120            parse(r#"{"video_frames": 4294967295}"#).video_frames,
1121            Some(crate::ALL_VIDEO_FRAMES)
1122        );
1123        assert_eq!(parse("{}").video_frames, None);
1124        assert!(serde_json::from_str::<ConvertOptions>(r#"{"video_frames": "some"}"#).is_err());
1125        // "all" survives a merge round trip (serialized as the u64 max).
1126        let merged = ConvertOptions {
1127            video_frames: Some(crate::ALL_VIDEO_FRAMES),
1128            ..Default::default()
1129        }
1130        .merge_over(ConvertOptions::default());
1131        assert_eq!(merged.video_frames, Some(crate::ALL_VIDEO_FRAMES));
1132
1133        let ok = ConvertOptions {
1134            video_scene_threshold: Some(0.6),
1135            video_frame_max_side: Some(640),
1136            video_frame_dedupe: Some(5),
1137            ..Default::default()
1138        };
1139        ok.validate().unwrap();
1140        let bad = ConvertOptions {
1141            video_scene_threshold: Some(1.5),
1142            ..Default::default()
1143        };
1144        assert_eq!(bad.validate().unwrap_err().field, "video_scene_threshold");
1145        let bad = ConvertOptions {
1146            video_frame_dedupe: Some(65),
1147            ..Default::default()
1148        };
1149        assert_eq!(bad.validate().unwrap_err().field, "video_frame_dedupe");
1150    }
1151
1152    #[test]
1153    fn merge_prefers_the_overlay_and_keeps_the_rest() {
1154        let base = ConvertOptions {
1155            strict: Some(true),
1156            ocr_scale: Some(2.0),
1157            pages: Some("1-3".into()),
1158            ..Default::default()
1159        };
1160        let over = ConvertOptions {
1161            strict: Some(false),
1162            video_frames: Some(0),
1163            ..Default::default()
1164        };
1165        let merged = over.merge_over(base);
1166        assert_eq!(merged.strict, Some(false));
1167        assert_eq!(merged.ocr_scale, Some(2.0));
1168        assert_eq!(merged.pages.as_deref(), Some("1-3"));
1169        assert_eq!(merged.video_frames, Some(0));
1170    }
1171
1172    #[test]
1173    fn validation_rules_and_cli_spelling() {
1174        let bad = |o: ConvertOptions| o.validate().unwrap_err();
1175        let e = bad(ConvertOptions {
1176            ocr_scale: Some(0.0),
1177            ..Default::default()
1178        });
1179        assert_eq!(e.field, "ocr_scale");
1180        assert_eq!(e.to_string(), "ocr_scale must be a positive number, got 0");
1181        assert_eq!(
1182            e.cli_message(),
1183            "--ocr-scale must be a positive number, got 0"
1184        );
1185
1186        let e = bad(ConvertOptions {
1187            images_scale: Some(9.0),
1188            ..Default::default()
1189        });
1190        assert_eq!(e.field, "images_scale");
1191        let e = bad(ConvertOptions {
1192            document_timeout: Some(-1.0),
1193            ..Default::default()
1194        });
1195        assert!(e.message.contains("document_timeout must be a positive"));
1196        let e = bad(ConvertOptions {
1197            pages: Some("5-3".into()),
1198            ..Default::default()
1199        });
1200        assert_eq!(e.field, "pages");
1201        assert!(
1202            e.cli_message().starts_with("--pages: "),
1203            "{}",
1204            e.cli_message()
1205        );
1206        let e = bad(ConvertOptions {
1207            pipeline: Some("magic".into()),
1208            ..Default::default()
1209        });
1210        assert!(e.message.contains("unknown pipeline"), "{e}");
1211        let e = bad(ConvertOptions {
1212            vlm_max_tokens: Some(0),
1213            ..Default::default()
1214        });
1215        assert_eq!(
1216            e.cli_message(),
1217            "--vlm-max-tokens must be a positive integer, got 0"
1218        );
1219        assert!(ConvertOptions::default().validate().is_ok());
1220    }
1221
1222    #[cfg(feature = "pdf")]
1223    #[test]
1224    fn ocr_options_are_validated_against_the_engine() {
1225        let e = ConvertOptions {
1226            ocr_engine: Some("easyocr".into()),
1227            ..Default::default()
1228        }
1229        .validate()
1230        .unwrap_err();
1231        assert_eq!(e.field, "ocr_engine");
1232        assert!(
1233            e.cli_message().starts_with("--ocr-engine \"easyocr\""),
1234            "{}",
1235            e.cli_message()
1236        );
1237        // `deu` is a language to Tesseract only.
1238        let e = ConvertOptions {
1239            ocr_engine: Some("ppocr".into()),
1240            ocr_lang: Some("deu".into()),
1241            ..Default::default()
1242        }
1243        .validate()
1244        .unwrap_err();
1245        assert_eq!(e.field, "ocr_lang");
1246        assert!(e.message.contains("ocr_lang"), "{e}");
1247        let tess = ConvertOptions {
1248            ocr_engine: Some("tesseract".into()),
1249            ocr_lang: Some("deu+fra".into()),
1250            ..Default::default()
1251        };
1252        assert!(tess.validate().is_ok());
1253        assert_eq!(tess.ocr_lang().unwrap(), None);
1254        assert_eq!(tess.tesseract_lang().unwrap().as_deref(), Some("deu+fra"));
1255        let e = ConvertOptions {
1256            ocr_mode: Some("sideways".into()),
1257            ..Default::default()
1258        }
1259        .validate()
1260        .unwrap_err();
1261        assert_eq!(e.field, "ocr_mode");
1262    }
1263
1264    #[test]
1265    fn apply_sets_only_what_is_given() {
1266        // `strict` and the page-break text ride on the finished document, so
1267        // they are observable without a conversion.
1268        let base = DocumentConverter::new().strict(true);
1269        let mut doc = docling_core::DoclingDocument::new("t");
1270        ConvertOptions::default()
1271            .apply(base.clone())
1272            .unwrap()
1273            .finish_document(&mut doc);
1274        assert!(doc.strict_markdown, "unset keeps the base's strict");
1275        let mut doc = docling_core::DoclingDocument::new("t");
1276        ConvertOptions {
1277            strict: Some(false),
1278            page_break_placeholder: Some("<!-- p -->".into()),
1279            ..Default::default()
1280        }
1281        .apply(base)
1282        .unwrap()
1283        .finish_document(&mut doc);
1284        assert!(!doc.strict_markdown);
1285        assert_eq!(doc.page_break_placeholder.as_deref(), Some("<!-- p -->"));
1286        // A rejected option never yields a converter.
1287        assert!(DocumentConverter::from_options(&ConvertOptions {
1288            pages: Some("0-2".into()),
1289            ..Default::default()
1290        })
1291        .is_err());
1292    }
1293
1294    #[cfg(feature = "vlm")]
1295    #[test]
1296    fn vlm_options_follow_the_pipeline_switch() {
1297        // Standard: stray vlm_* options are ignored, nothing resolves.
1298        let stray = ConvertOptions {
1299            vlm_endpoint: Some("http://127.0.0.1:9/v1".into()),
1300            ..Default::default()
1301        };
1302        assert!(matches!(stray.vlm_options(), Ok(None)));
1303        let full = ConvertOptions {
1304            pipeline: Some("vlm".into()),
1305            vlm_endpoint: Some("http://example.com/v1".into()),
1306            vlm_model: Some("m".into()),
1307            vlm_api_key: Some("sk-test".into()),
1308            vlm_prompt: Some("Read the page.".into()),
1309            vlm_max_tokens: Some(512),
1310            pages: Some("2-5".into()),
1311            ..Default::default()
1312        };
1313        let v = full.vlm_options().unwrap().expect("resolves");
1314        assert_eq!(v.endpoint, "http://example.com/v1");
1315        assert_eq!(v.model, "m");
1316        assert_eq!(v.api_key.as_deref(), Some("sk-test"));
1317        assert_eq!(v.prompt.as_deref(), Some("Read the page."));
1318        assert_eq!(v.max_tokens, 512);
1319        assert_eq!(v.page_range, Some((2, 5)));
1320        let e = ConvertOptions {
1321            vlm_max_tokens: Some(0),
1322            ..full.clone()
1323        }
1324        .vlm_options()
1325        .unwrap_err();
1326        assert_eq!(e.field, "vlm_max_tokens");
1327        // No model anywhere: the message names the wire option, the CLI the
1328        // flag. (`DOCLING_RS_VLM_MODEL` unset in the test environment.)
1329        if std::env::var_os("DOCLING_RS_VLM_MODEL").is_none() {
1330            let e = ConvertOptions {
1331                vlm_model: None,
1332                ..full
1333            }
1334            .vlm_options()
1335            .unwrap_err();
1336            assert_eq!(e.field, "vlm_model");
1337            assert!(e.message.contains("set vlm_model"), "{e}");
1338            assert!(
1339                e.cli_message().contains("--vlm-model"),
1340                "{}",
1341                e.cli_message()
1342            );
1343            assert!(e.message.contains("DOCLING_RS_VLM_MODEL"), "{e}");
1344        }
1345    }
1346
1347    /// #611: `no_ocr` is docling's `do_ocr=False` and `skip_ocr` its pre-2.0
1348    /// name — either one turns OCR off, and a body carrying both (a pre-2.0
1349    /// client sending both switches) parses instead of failing on a
1350    /// duplicate field; `text_layer_only` and `password` — under docling's
1351    /// `pdf_password` too (#625) — are read.
1352    #[test]
1353    fn ocr_spellings_and_password_parse() {
1354        let both: ConvertOptions = serde_json::from_str(
1355            r#"{"no_ocr": false, "skip_ocr": true, "text_layer_only": true,
1356                "pdf_password": "1234"}"#,
1357        )
1358        .expect("both spellings parse");
1359        assert!(both.ocr_disabled());
1360        assert_eq!(both.text_layer_only, Some(true));
1361        assert_eq!(both.password.as_deref(), Some("1234"));
1362        let neutral: ConvertOptions = serde_json::from_str(r#"{"password": "x"}"#).unwrap();
1363        assert_eq!(neutral.password.as_deref(), Some("x"));
1364        let new: ConvertOptions = serde_json::from_str(r#"{"no_ocr": true}"#).unwrap();
1365        assert!(new.ocr_disabled());
1366        assert!(!ConvertOptions::default().ocr_disabled());
1367        // Merged like every option: a later layer's spelling adds to the base.
1368        let merged = new.merge_over(both);
1369        assert!(merged.ocr_disabled());
1370        assert_eq!(merged.password.as_deref(), Some("1234"));
1371    }
1372}