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::{
45    CustomPattern, ImageRedaction, MetadataOutput, PiiKind, RedactionOptions, Replacement,
46};
47
48use crate::DocumentConverter;
49
50/// Conversion options as every surface accepts them — see the module docs.
51/// `None` everywhere is the engine's defaults.
52#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
53#[serde(default)]
54pub struct ConvertOptions {
55    // --- output shaping carried on the document -----------------------------
56    /// Cleaner, more conformant Markdown ([`DocumentConverter::strict`]).
57    pub strict: Option<bool>,
58    /// Compact `| a | b |` Markdown tables instead of width padding (#271).
59    pub compact_tables: Option<bool>,
60    /// Render pictures' alt text (OOXML `descr`/`title`, #676) in Markdown
61    /// and HTML; the JSON carries it always. Strict Markdown implies it.
62    pub picture_alt_text: Option<bool>,
63    /// Text inserted between pages in the Markdown export (docling-core's
64    /// `MarkdownParams.page_break_placeholder`); docling-serve's historical
65    /// spelling `md_page_break_placeholder` is accepted as an alias.
66    #[serde(alias = "md_page_break_placeholder")]
67    pub page_break_placeholder: Option<String>,
68
69    // --- declarative formats -------------------------------------------------
70    /// Resolve external `<img src>` for HTML/EPUB/MHTML/JATS (network access)
71    /// — the pre-#646 switch: `true` is `image_sources=remote`, `false` is
72    /// `none`; an explicit `image_sources` wins.
73    pub fetch_images: Option<bool>,
74    /// Which image references resolve (#646): `none` (default — every picture
75    /// a placeholder) | `embedded` (`data:` URIs and parts of the same
76    /// container: EPUB/MHTML entries, email `cid:` attachments; no
77    /// filesystem or network) | `local` (plus files under the source file's
78    /// directory) | `remote` (plus `http(s)` fetches). HTML, EPUB, MHTML,
79    /// JATS, AsciiDoc, ODF, Markdown and email bodies.
80    pub image_sources: Option<String>,
81    /// Hosts a `remote` image fetch may reach (#646): exact names or
82    /// `*.suffix` wildcards, a list or a comma-separated string; unset =
83    /// any host. A redirect is held to the same list.
84    #[serde(default, deserialize_with = "de_string_list")]
85    pub image_hosts: Option<Vec<String>>,
86    /// Largest image that resolves, in bytes (#646; 32 MiB,
87    /// `DOCLING_RS_MAX_IMAGE_BYTES`).
88    pub max_image_bytes: Option<u64>,
89    /// How many images resolve per document; the rest stay placeholders
90    /// (#646; unlimited, `DOCLING_RS_MAX_IMAGES`).
91    pub max_images: Option<usize>,
92    /// Total resolved image bytes per document, in MiB (#646; unlimited,
93    /// `DOCLING_RS_MAX_IMAGE_TOTAL_MB`).
94    pub max_image_total_mb: Option<u64>,
95    /// Smallest image that resolves, in bytes — skips spacer / tracking
96    /// pixels (#646; 0, `DOCLING_RS_MIN_IMAGE_BYTES`).
97    pub min_image_bytes: Option<u64>,
98    /// Email (.eml/.msg): append an Attachments section (#251).
99    pub list_attachments: Option<bool>,
100    /// Omit empty cells from sparse XLSX/XLS table grids (#271).
101    pub skip_empty_cells: Option<bool>,
102    /// EBCDIC copybook layout (#252): inline JSON or a file path.
103    pub ebcdic_layout: Option<String>,
104    /// Character encoding of text inputs (a WHATWG label); unset = detect.
105    pub encoding: Option<String>,
106    /// Jupyter notebooks (#673): which cell outputs the document keeps —
107    /// `none` (default: the notebook as written) or `text` (stream and
108    /// `text/plain` results as code blocks, image outputs as pictures).
109    pub ipynb_outputs: Option<String>,
110    /// Document metadata (#679): where the file's own metadata (EPUB
111    /// Dublin Core, Office properties, EXIF, audio tags, PDF Info) goes —
112    /// `none` (default: nowhere, every export unchanged), `json` (a
113    /// `metadata` object in the docling JSON) or `frontmatter` (that, and
114    /// YAML front matter at the top of the Markdown).
115    pub metadata: Option<String>,
116    /// Pre-render HTML with a headless browser (the `web-browser` feature).
117    pub use_web_browser: Option<bool>,
118    /// XBRL: the directory the instance's taxonomy is read from.
119    pub xbrl_taxonomy: Option<String>,
120
121    // --- audio / video -------------------------------------------------------
122    /// ASR preset (`whisper_*`, `parakeet_tdt_0.6b_v3`); unset = Whisper tiny.
123    pub asr_model: Option<String>,
124    /// Transcription language (a Whisper code) or `auto`.
125    pub asr_lang: Option<String>,
126    /// Max frames sampled from a video (0 = transcript only); unset =
127    /// [`crate::DEFAULT_VIDEO_FRAMES`]. `"all"` — or any count at or above
128    /// `u32::MAX` — is [`crate::ALL_VIDEO_FRAMES`]: every distinct cut (#647).
129    #[serde(deserialize_with = "de_video_frames")]
130    pub video_frames: Option<usize>,
131    /// ffmpeg's scene score (0–1) a video frame must exceed to be a cut
132    /// (#647); unset = 0.27 (`DOCLING_RS_VIDEO_SCENE_THRESHOLD`).
133    pub video_scene_threshold: Option<f32>,
134    /// Cap on a sampled video frame's longer side in px, applied inside
135    /// ffmpeg (#647); 0 / unset = the source resolution
136    /// (`DOCLING_RS_VIDEO_FRAME_MAX_SIDE`).
137    pub video_frame_max_side: Option<u32>,
138    /// Hamming distance (0–64) of the difference hash under which a sampled
139    /// frame is a duplicate of a kept one and dropped (#647); unset = keep
140    /// every frame (`DOCLING_RS_VIDEO_FRAME_DEDUPE`).
141    pub video_frame_dedupe: Option<u32>,
142
143    // --- PDF / image pipeline ------------------------------------------------
144    /// PDF page window, `"A-B"` or a single `"N"` (1-based inclusive, #80).
145    pub pages: Option<String>,
146    /// Per-document budget in seconds for the PDF pipeline (#497).
147    pub document_timeout: Option<f64>,
148    /// Skip the whole ML stack: text layer only (#611; `no_ocr` before 2.0).
149    pub text_layer_only: Option<bool>,
150    /// Keep layout + TableFormer, never run OCR — docling's `do_ocr=False`,
151    /// its CLI's `--no-ocr` (#611).
152    pub no_ocr: Option<bool>,
153    /// `no_ocr` under its pre-2.0 name (#244), still read: either one set
154    /// skips OCR ([`Self::ocr_disabled`]). A field of its own rather than a
155    /// serde alias, so a body that sends both spellings (a pre-2.0 form)
156    /// is not a duplicate-field error.
157    pub skip_ocr: Option<bool>,
158    /// The password of an encrypted PDF or Office document (#611, #625).
159    /// `pdf_password`, docling's PDF-only name (`--pdf-password`), is read
160    /// as an alias.
161    #[serde(alias = "pdf_password")]
162    pub password: Option<String>,
163    /// OCR every page, discarding the text layer.
164    pub force_full_page_ocr: Option<bool>,
165    /// Skip TableFormer (geometric tables instead).
166    pub no_table_former: Option<bool>,
167    /// Disable the text-panel heuristic (#173).
168    pub no_text_panels: Option<bool>,
169    /// Infer heading levels after assembly (#302).
170    pub heading_hierarchy: Option<bool>,
171    /// `ppocr` (default) | `tesseract` (#460).
172    pub ocr_engine: Option<String>,
173    /// OCR language: `en` | `ch` or a BCP-47 tag under PP-OCR, tessdata stems
174    /// or BCP-47 tags under Tesseract.
175    pub ocr_lang: Option<String>,
176    /// docling's `OcrMode` (#254): `default` | `full_page` | `layout_regions`
177    /// | `pdf_aware_layout_regions`.
178    pub ocr_mode: Option<String>,
179    /// OCR input scale in px per PDF point (#254); unset = the 2.0 render.
180    pub ocr_scale: Option<f32>,
181    /// RapidOCR's text-line orientation handling (#666): oriented crops, the
182    /// 90° turn of tall lines and the 0/180 classifier. Default off
183    /// (`DOCLING_RS_OCR_TEXTLINE_ORIENTATION`).
184    pub ocr_textline_orientation: Option<bool>,
185    /// Picture-crop / page-image scale in px per point, 0.1–4.0 (#520).
186    pub images_scale: Option<f32>,
187    /// Keep each page's render as the JSON page image (docling's
188    /// `generate_page_images`, #520 — accepted as an alias).
189    #[serde(alias = "generate_page_images")]
190    pub page_images: Option<bool>,
191    /// Enrichment: classify pictures with DocumentFigureClassifier (#423).
192    pub do_picture_classification: Option<bool>,
193    /// Enrichment: rewrite code blocks with CodeFormulaV2 (#423).
194    pub do_code_enrichment: Option<bool>,
195    /// Enrichment: decode display formulas to LaTeX with CodeFormulaV2 (#423).
196    pub do_formula_enrichment: Option<bool>,
197    /// Enrichment: OCR the embedded pictures of non-PDF documents (#645);
198    /// the text becomes the picture's description annotation.
199    pub do_picture_ocr: Option<bool>,
200    /// Comma-separated DocumentFigureClassifier labels a picture must be
201    /// classified as to be OCR'd (#645); unset/empty = every picture.
202    pub picture_ocr_classes: Option<String>,
203    /// Smallest side in px a picture must have to be OCR'd (#645; default
204    /// 32, `DOCLING_RS_PICTURE_OCR_MIN_SIDE`).
205    pub picture_ocr_min_side: Option<u32>,
206    /// Picture OCR resolution in px per picture pixel before the 2000 px cap
207    /// (#666); `1.0` = RapidOCR's native pixels. Unset = the image-input
208    /// rule (`DOCLING_RS_PICTURE_OCR_SCALE`).
209    pub picture_ocr_scale: Option<f32>,
210    /// Keep the embedded image bytes on the pictures (#645); `false` drops
211    /// them after the enrichment pass. Default `true`.
212    pub keep_picture_images: Option<bool>,
213    /// Enrichment: describe the pictures of every format through an
214    /// OpenAI-compatible vision endpoint (#670) — the answer is the
215    /// picture's description annotation, as `do_picture_ocr` writes it.
216    pub do_picture_description: Option<bool>,
217    /// The vision endpoint (`/v1` base or the full `chat/completions` URL);
218    /// falls back to `vlm_endpoint`, then `DOCLING_RS_VLM_ENDPOINT`.
219    pub picture_description_endpoint: Option<String>,
220    /// Model name; falls back to `vlm_model`, then `DOCLING_RS_VLM_MODEL`.
221    pub picture_description_model: Option<String>,
222    /// Bearer token; falls back to `vlm_api_key`, then `DOCLING_RS_VLM_API_KEY`.
223    pub picture_description_api_key: Option<String>,
224    /// The instruction sent with every picture; unset = the mode's prompt
225    /// (MarkItDown's caption prompt, markitdown-ocr's extraction prompt).
226    pub picture_description_prompt: Option<String>,
227    /// `caption` (default) | `ocr`: a caption, or the text in the picture.
228    pub picture_description_mode: Option<String>,
229    /// Pictures in flight at once (default 1); 0 is rejected.
230    pub picture_description_concurrency: Option<usize>,
231
232    // --- PII redaction (#621) ---------------------------------------------
233    /// Redact personal data from the converted document before any export.
234    pub redact_pii: Option<bool>,
235    /// What a span becomes: `label` (default) | `pseudonym` | `fixed:<text>`.
236    pub redact_mode: Option<String>,
237    /// Comma-separated kinds to redact (`email,phone,credit_card,iban,
238    /// ip_address,url_credentials,national_id,person,organization,
239    /// location,address`); unset = every kind.
240    pub redact_kinds: Option<String>,
241    /// Extra patterns, `NAME=REGEX` entries separated by newlines (the CLI
242    /// flag repeats); the match (or capture group 1) is redacted as `[NAME]`.
243    pub redact_pattern: Option<String>,
244    /// Embedded images and page renders: `drop` (default) | `box_out` | `keep`.
245    pub redact_images: Option<String>,
246    /// Comma-separated terms never redacted even when a detector flags
247    /// them (#694) — `support@example.com`, the company's own name.
248    pub redact_allow_terms: Option<String>,
249    /// Comma-separated terms always redacted, as `[REDACTED]` — a project
250    /// code name, a client the NER misses.
251    pub redact_deny_terms: Option<String>,
252    /// Regexes (one per line) a flagged span must not match in full to be
253    /// redacted — a document's own number shapes: part numbers, record IDs.
254    pub redact_allow_patterns: Option<String>,
255    /// The NER detector's minimum confidence, 0–1 (default 0.85).
256    pub redact_ner_min_score: Option<f32>,
257
258    // --- pipeline selection --------------------------------------------------
259    /// `standard` (default) | `vlm` (#77): the remote vision model instead of
260    /// the local ML stack. The `vlm_*` options are inert under `standard`.
261    pub pipeline: Option<String>,
262    /// VLM endpoint; falls back to `DOCLING_RS_VLM_ENDPOINT`.
263    pub vlm_endpoint: Option<String>,
264    /// VLM model name; falls back to `DOCLING_RS_VLM_MODEL`.
265    pub vlm_model: Option<String>,
266    /// Bearer token for the endpoint; falls back to `DOCLING_RS_VLM_API_KEY`.
267    pub vlm_api_key: Option<String>,
268    /// Per-page instruction; falls back to `DOCLING_RS_VLM_PROMPT`.
269    pub vlm_prompt: Option<String>,
270    /// `max_tokens` per completion (default 8192); 0 is rejected.
271    pub vlm_max_tokens: Option<usize>,
272}
273
274/// What a picture is asked for (#670): a caption, or the text it carries.
275#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
276pub enum PictureDescriptionMode {
277    /// MarkItDown's `llm_caption`: "Write a detailed caption for this image."
278    #[default]
279    Caption,
280    /// markitdown-ocr's extraction prompt: the text only, layout kept, no
281    /// commentary — LLM-vision OCR for pictures the local OCR models read
282    /// poorly (handwriting, stylized text, charts).
283    Ocr,
284}
285
286impl PictureDescriptionMode {
287    /// `caption` | `ocr`.
288    pub fn parse(s: &str) -> Option<Self> {
289        match s.trim().to_ascii_lowercase().as_str() {
290            "caption" => Some(Self::Caption),
291            "ocr" => Some(Self::Ocr),
292            _ => None,
293        }
294    }
295
296    pub fn as_str(self) -> &'static str {
297        match self {
298            Self::Caption => "caption",
299            Self::Ocr => "ocr",
300        }
301    }
302
303    /// The prompt sent when the options name none.
304    pub fn default_prompt(self) -> &'static str {
305        match self {
306            Self::Caption => CAPTION_PROMPT,
307            Self::Ocr => OCR_PROMPT,
308        }
309    }
310}
311
312/// MarkItDown's caption prompt (`_llm_caption.py`), verbatim.
313pub const CAPTION_PROMPT: &str = "Write a detailed caption for this image.";
314
315/// markitdown-ocr's extraction prompt (`_ocr_service.py`), verbatim.
316pub const OCR_PROMPT: &str = "Extract all text from this image. Return ONLY the extracted text, \
317                              maintaining the original layout and order. Do not add any commentary \
318                              or description.";
319
320/// A rejected option: which field, and why. The message names the field in
321/// its wire spelling (`ocr_scale must be a positive number, got 0`); the CLI
322/// prints it through [`OptionsError::cli_message`], which swaps in the flag.
323#[derive(Debug, Clone, PartialEq, Eq)]
324pub struct OptionsError {
325    /// The wire name of the offending option — a [`ConvertOptions`] field.
326    pub field: &'static str,
327    /// Why it was rejected; names the field.
328    pub message: String,
329}
330
331impl OptionsError {
332    fn new(field: &'static str, message: impl Into<String>) -> Self {
333        Self {
334            field,
335            message: message.into(),
336        }
337    }
338
339    /// The message with the field spelled as its CLI flag (`--ocr-scale must
340    /// be a positive number, got 0`).
341    pub fn cli_message(&self) -> String {
342        let flag = cli_flag(self.field);
343        if self.message.contains(self.field) {
344            self.message.replacen(self.field, &flag, 1)
345        } else {
346            format!("{flag}: {}", self.message)
347        }
348    }
349}
350
351impl fmt::Display for OptionsError {
352    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
353        f.write_str(&self.message)
354    }
355}
356
357impl std::error::Error for OptionsError {}
358
359/// Which conversion pipeline an option set selects.
360#[derive(Debug, Clone, Copy, PartialEq, Eq)]
361pub enum PipelineKind {
362    /// The local ML stack (layout, TableFormer, OCR) — the default.
363    Standard,
364    /// A remote OpenAI-compatible vision model (#77).
365    Vlm,
366}
367
368/// How one option is spelled on each surface, where it differs from the wire
369/// name — the central documentation the inventory test holds every surface
370/// to. One row per [`ConvertOptions`] field (the test checks both ways).
371#[derive(Debug, Clone, Copy)]
372pub struct OptionInfo {
373    /// The field / wire name.
374    pub name: &'static str,
375    /// Other wire spellings accepted on deserialization (`serde(alias)`).
376    pub aliases: &'static [&'static str],
377    /// The CLI flag; `None` = the default `--name-with-dashes`.
378    pub cli: Option<&'static str>,
379    /// The Python `DocumentConverter(...)` keyword argument; `Some("")` when
380    /// the Python surface has no such argument (the reason is in the row's
381    /// comment); `None` = the field name itself.
382    pub python: Option<&'static str>,
383    /// The Node option (napi spells the field in camelCase); `Some("")` when
384    /// the Node surface has no such option (reason in the row's comment);
385    /// `None` = the camelCase of the field name.
386    pub node: Option<&'static str>,
387}
388
389const fn row(name: &'static str) -> OptionInfo {
390    OptionInfo {
391        name,
392        aliases: &[],
393        cli: None,
394        python: None,
395        node: None,
396    }
397}
398
399/// The per-surface spelling table — see [`OptionInfo`].
400pub const OPTIONS: &[OptionInfo] = &[
401    // Python picks Markdown strictness at export time (`export_to_markdown`
402    // reads `document.strict_markdown`), so the converter takes no kwarg.
403    OptionInfo {
404        python: Some(""),
405        ..row("strict")
406    },
407    row("compact_tables"),
408    row("picture_alt_text"),
409    // Python: `export_to_markdown(page_break_placeholder=…)`, docling's own
410    // signature, not a converter kwarg.
411    OptionInfo {
412        aliases: &["md_page_break_placeholder"],
413        python: Some(""),
414        ..row("page_break_placeholder")
415    },
416    row("fetch_images"),
417    row("image_sources"),
418    row("image_hosts"),
419    row("max_image_bytes"),
420    row("max_images"),
421    row("max_image_total_mb"),
422    row("min_image_bytes"),
423    row("list_attachments"),
424    row("skip_empty_cells"),
425    row("ebcdic_layout"),
426    row("encoding"),
427    row("ipynb_outputs"),
428    row("metadata"),
429    // The npm addon is not built with the `web-browser` feature, so the
430    // option would only ever error there.
431    OptionInfo {
432        node: Some(""),
433        ..row("use_web_browser")
434    },
435    row("xbrl_taxonomy"),
436    row("asr_model"),
437    row("asr_lang"),
438    row("video_frames"),
439    row("video_scene_threshold"),
440    row("video_frame_max_side"),
441    row("video_frame_dedupe"),
442    // Python takes docling's `page_range=(first, last)` tuple.
443    OptionInfo {
444        python: Some("page_range"),
445        ..row("pages")
446    },
447    row("document_timeout"),
448    // Python keeps docling's positive spellings: `do_ocr`,
449    // `do_table_structure` — and the docling.rs-only `text_layer_only`.
450    // `no_ocr` is docling's `--no-ocr` since 2.0 (#611); `skip_ocr` is its
451    // pre-2.0 name, still read (the CLI's `--skip-ocr` too) — Python has
452    // docling's `do_ocr` for both.
453    row("text_layer_only"),
454    OptionInfo {
455        python: Some("do_ocr"),
456        ..row("no_ocr")
457    },
458    OptionInfo {
459        python: Some(""),
460        ..row("skip_ocr")
461    },
462    // One password for PDFs and Office documents (#625); docling's
463    // PDF-only `pdf_password` stays readable on every surface.
464    OptionInfo {
465        aliases: &["pdf_password"],
466        ..row("password")
467    },
468    row("force_full_page_ocr"),
469    OptionInfo {
470        python: Some("do_table_structure"),
471        ..row("no_table_former")
472    },
473    row("no_text_panels"),
474    row("heading_hierarchy"),
475    row("ocr_engine"),
476    row("ocr_lang"),
477    row("ocr_mode"),
478    row("ocr_scale"),
479    row("ocr_textline_orientation"),
480    row("images_scale"),
481    OptionInfo {
482        aliases: &["generate_page_images"],
483        python: Some("generate_page_images"),
484        ..row("page_images")
485    },
486    OptionInfo {
487        cli: Some("--enrich-picture-classes"),
488        ..row("do_picture_classification")
489    },
490    OptionInfo {
491        cli: Some("--enrich-code"),
492        ..row("do_code_enrichment")
493    },
494    OptionInfo {
495        cli: Some("--enrich-formula"),
496        ..row("do_formula_enrichment")
497    },
498    OptionInfo {
499        cli: Some("--picture-ocr"),
500        ..row("do_picture_ocr")
501    },
502    row("picture_ocr_classes"),
503    row("picture_ocr_min_side"),
504    row("picture_ocr_scale"),
505    OptionInfo {
506        cli: Some("--no-picture-images"),
507        ..row("keep_picture_images")
508    },
509    OptionInfo {
510        cli: Some("--picture-description"),
511        ..row("do_picture_description")
512    },
513    row("picture_description_endpoint"),
514    row("picture_description_model"),
515    row("picture_description_api_key"),
516    row("picture_description_prompt"),
517    row("picture_description_mode"),
518    row("picture_description_concurrency"),
519    row("redact_pii"),
520    row("redact_mode"),
521    row("redact_kinds"),
522    row("redact_pattern"),
523    row("redact_images"),
524    row("redact_allow_terms"),
525    row("redact_deny_terms"),
526    OptionInfo {
527        cli: Some("--redact-allow-pattern"),
528        ..row("redact_allow_patterns")
529    },
530    row("redact_ner_min_score"),
531    row("pipeline"),
532    row("vlm_endpoint"),
533    row("vlm_model"),
534    row("vlm_api_key"),
535    row("vlm_prompt"),
536    row("vlm_max_tokens"),
537];
538
539/// The CLI flag for a [`ConvertOptions`] field: its [`OPTIONS`] row's `cli`,
540/// else `--` + the name with `_` → `-`.
541pub fn cli_flag(field: &str) -> String {
542    OPTIONS
543        .iter()
544        .find(|o| o.name == field)
545        .and_then(|o| o.cli)
546        .map(str::to_string)
547        .unwrap_or_else(|| format!("--{}", field.replace('_', "-")))
548}
549
550/// `over` on top of `base`: every field `over` sets (serializes non-null)
551/// replaces `base`'s, every one it leaves unset keeps `base`'s — for any
552/// all-`Option` options struct, which is why docling-serve's request struct
553/// (this one plus its output fields) merges its query / body / form layers
554/// through it without a per-field list.
555pub fn merge_options<T: Serialize + DeserializeOwned + Default>(over: T, base: T) -> T {
556    let mut merged = serde_json::to_value(base).unwrap_or(serde_json::Value::Null);
557    let over = serde_json::to_value(over).unwrap_or(serde_json::Value::Null);
558    if let (Some(into), Some(from)) = (merged.as_object_mut(), over.as_object()) {
559        for (key, value) in from {
560            if !value.is_null() {
561                into.insert(key.clone(), value.clone());
562            }
563        }
564    }
565    // A struct that serialized to an object deserializes from it; the
566    // fallback only guards a `T` that is not an object at all.
567    serde_json::from_value(merged).unwrap_or_default()
568}
569
570/// A list option on the wire: a JSON array, or one string the text surfaces
571/// (query, multipart) send — split on commas, trimmed, empties dropped.
572fn de_string_list<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<Vec<String>>, D::Error> {
573    #[derive(Deserialize)]
574    #[serde(untagged)]
575    enum Raw {
576        List(Vec<String>),
577        Text(String),
578    }
579    Ok(Option::<Raw>::deserialize(d)?.map(|raw| {
580        let items: Vec<String> = match raw {
581            Raw::List(v) => v,
582            Raw::Text(s) => s.split(',').map(str::to_string).collect(),
583        };
584        items
585            .into_iter()
586            .map(|s| s.trim().to_string())
587            .filter(|s| !s.is_empty())
588            .collect()
589    }))
590}
591
592/// `video_frames` on the wire (#647): an integer, or `"all"` (any case,
593/// also a digit string, which the text surfaces send) for
594/// [`crate::ALL_VIDEO_FRAMES`]; a count at or above `u32::MAX` reads as
595/// "all" too, so a 32-bit binding can ask for it.
596fn de_video_frames<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<usize>, D::Error> {
597    #[derive(Deserialize)]
598    #[serde(untagged)]
599    enum Raw {
600        Count(u64),
601        Text(String),
602    }
603    let raw = Option::<Raw>::deserialize(d)?;
604    let n = match raw {
605        None => return Ok(None),
606        Some(Raw::Count(n)) => n,
607        Some(Raw::Text(s)) => {
608            let s = s.trim();
609            if s.eq_ignore_ascii_case("all") {
610                return Ok(Some(crate::ALL_VIDEO_FRAMES));
611            }
612            s.parse::<u64>().map_err(|_| {
613                serde::de::Error::custom(format!(
614                    "video_frames: expected a non-negative integer or \"all\", got {s:?}"
615                ))
616            })?
617        }
618    };
619    Ok(Some(if n >= u32::MAX as u64 {
620        crate::ALL_VIDEO_FRAMES
621    } else {
622        n as usize
623    }))
624}
625
626impl ConvertOptions {
627    /// The image tier `image_sources` names (#646); `None` when unset (the
628    /// `fetch_images` alias, else the converter's default, decides).
629    pub fn image_sources(&self) -> Result<Option<crate::ImageSources>, OptionsError> {
630        self.image_sources
631            .as_deref()
632            .filter(|s| !s.trim().is_empty())
633            .map(|s| {
634                s.parse::<crate::ImageSources>()
635                    .map_err(|e| OptionsError::new("image_sources", e))
636            })
637            .transpose()
638    }
639
640    /// The notebook outputs `ipynb_outputs` names (#673); `None` when unset
641    /// (the converter's default, `none`).
642    pub fn ipynb_outputs(&self) -> Result<Option<crate::IpynbOutputs>, OptionsError> {
643        self.ipynb_outputs
644            .as_deref()
645            .filter(|s| !s.trim().is_empty())
646            .map(|s| {
647                s.parse::<crate::IpynbOutputs>()
648                    .map_err(|e| OptionsError::new("ipynb_outputs", e))
649            })
650            .transpose()
651    }
652
653    /// The metadata output mode (#679): `None` unless `metadata` is set
654    /// and non-blank; a spelling other than `none`, `json`, `frontmatter`
655    /// is an error.
656    pub fn metadata_output(&self) -> Result<Option<MetadataOutput>, OptionsError> {
657        self.metadata
658            .as_deref()
659            .filter(|s| !s.trim().is_empty())
660            .map(|s| {
661                MetadataOutput::parse(s).ok_or_else(|| {
662                    OptionsError::new(
663                        "metadata",
664                        format!(
665                            "metadata must be one of {}; got {s:?}",
666                            MetadataOutput::ACCEPTED
667                        ),
668                    )
669                })
670            })
671            .transpose()
672    }
673
674    /// The wire names of every field, from the struct itself (an unset
675    /// option serializes as `null`), so a new field is listed without a
676    /// hand-kept list — the inventory test and the unknown-key checks of the
677    /// JSON surfaces read it.
678    pub fn field_names() -> Vec<String> {
679        match serde_json::to_value(Self::default()) {
680            Ok(serde_json::Value::Object(map)) => map.keys().cloned().collect(),
681            _ => Vec::new(),
682        }
683    }
684
685    /// The keys among `keys` that are neither a field, one of its aliases,
686    /// nor in `also_known` — what a JSON surface that rejects typos (the C
687    /// ABI, the wasm module) reports.
688    pub fn unknown_keys<'a>(
689        keys: impl IntoIterator<Item = &'a str>,
690        also_known: &[&str],
691    ) -> Vec<String> {
692        let fields = Self::field_names();
693        keys.into_iter()
694            .filter(|k| {
695                !fields.iter().any(|f| f == k)
696                    && !also_known.contains(k)
697                    && !OPTIONS.iter().any(|o| o.aliases.contains(k))
698            })
699            .map(str::to_string)
700            .collect()
701    }
702
703    /// `self` on top of `base` — see [`merge_options`].
704    pub fn merge_over(self, base: ConvertOptions) -> ConvertOptions {
705        merge_options(self, base)
706    }
707
708    /// The readings of a text value, in the order a text surface tries
709    /// them: the string itself, an integer, a decimal number, then a
710    /// boolean (`1`/`true`/`yes`/`on`, `0`/`false`/`no`/`off`, any case).
711    pub fn text_candidates(text: &str) -> Vec<serde_json::Value> {
712        let trimmed = text.trim();
713        let mut candidates = vec![serde_json::Value::String(text.to_string())];
714        if let Ok(n) = trimmed.parse::<u64>() {
715            candidates.push(serde_json::Value::from(n));
716        } else if let Ok(f) = trimmed.parse::<f64>() {
717            if let Some(n) = serde_json::Number::from_f64(f) {
718                candidates.push(serde_json::Value::Number(n));
719            }
720        }
721        match trimmed.to_ascii_lowercase().as_str() {
722            "1" | "true" | "yes" | "on" => candidates.push(serde_json::Value::Bool(true)),
723            "0" | "false" | "no" | "off" => candidates.push(serde_json::Value::Bool(false)),
724            _ => {}
725        }
726        candidates
727    }
728
729    /// Set one option from its text form — a query parameter, a multipart
730    /// text part, the string an MCP client's form sends for every value.
731    /// The first reading of [`text_candidates`](Self::text_candidates) the
732    /// option accepts lands: `pages=5` stays the text `"5"`, `strict=true`
733    /// / `strict=1` / `strict=yes` set the flag, `ocr_scale=1.5` the number.
734    /// A name (or alias) that is no option is left alone (`Ok(false)`); a
735    /// value no reading accepts — `strict=maybe`, `document_timeout=soon`
736    /// — is an error naming the option.
737    pub fn set_text(&mut self, name: &str, text: &str) -> Result<bool, OptionsError> {
738        let Some(field) = OPTIONS
739            .iter()
740            .find(|o| o.name == name || o.aliases.contains(&name))
741            .map(|o| o.name)
742        else {
743            return Ok(false);
744        };
745        for value in Self::text_candidates(text) {
746            let mut patch = serde_json::Map::new();
747            patch.insert(name.to_string(), value);
748            if let Ok(parsed) =
749                serde_json::from_value::<ConvertOptions>(serde_json::Value::Object(patch))
750            {
751                *self = parsed.merge_over(std::mem::take(self));
752                return Ok(true);
753            }
754        }
755        Err(OptionsError::new(
756            field,
757            format!("{name}: cannot read {text:?} as that option's value"),
758        ))
759    }
760
761    /// A JSON object of options whose values may be text: what an MCP
762    /// client's form sends (MCP Inspector types every value as a string —
763    /// `{"force_full_page_ocr": "true", "ocr_scale": "1.5"}`), or a
764    /// hand-written call. A text value is read with
765    /// [`set_text`](Self::set_text); a typed value as the JSON it is, or,
766    /// when the option is text (`{"pages": 3}`) or the type is off
767    /// (`{"strict": 1}`), as its text; `null` leaves the option unset. An
768    /// unknown key, or a value no reading accepts, is an error naming it.
769    pub fn from_loose_json(
770        map: serde_json::Map<String, serde_json::Value>,
771    ) -> Result<Self, OptionsError> {
772        let mut out = Self::default();
773        for (name, value) in map {
774            let known = OPTIONS
775                .iter()
776                .find(|o| o.name == name || o.aliases.contains(&name.as_str()))
777                .map(|o| o.name);
778            let Some(field) = known else {
779                return Err(OptionsError::new(
780                    "options",
781                    format!("unknown field `{name}` (see docs/OPTIONS.md for the list)"),
782                ));
783            };
784            match value {
785                serde_json::Value::Null => {}
786                serde_json::Value::String(text) => {
787                    out.set_text(&name, &text)?;
788                }
789                other => {
790                    let text = match &other {
791                        serde_json::Value::Number(n) => Some(n.to_string()),
792                        serde_json::Value::Bool(b) => Some(b.to_string()),
793                        _ => None,
794                    };
795                    let mut patch = serde_json::Map::new();
796                    patch.insert(name.clone(), other);
797                    match serde_json::from_value::<ConvertOptions>(serde_json::Value::Object(patch))
798                    {
799                        Ok(parsed) => out = parsed.merge_over(out),
800                        Err(e) => match text {
801                            Some(t) if out.set_text(&name, &t).is_ok() => {}
802                            _ => return Err(OptionsError::new(field, format!("{name}: {e}"))),
803                        },
804                    }
805                }
806            }
807        }
808        Ok(out)
809    }
810
811    /// Check every option that can be checked without the input document —
812    /// the one set of rules all surfaces apply, before any work starts:
813    /// `pages` spelling, a positive `document_timeout`, a positive
814    /// `ocr_scale` and `picture_ocr_scale`, `images_scale` in 0.1–4.0, a
815    /// known `pipeline`, a positive
816    /// `vlm_max_tokens`, and (with the `pdf` feature, which has the engine
817    /// tables) a known `ocr_engine` / `ocr_mode`, an `ocr_lang` the
818    /// selected engine reads and `picture_ocr_classes` the classifier
819    /// predicts. `asr_lang`, `encoding` and `ebcdic_layout` are
820    /// checked against the model / codec / copybook when the conversion runs.
821    pub fn validate(&self) -> Result<(), OptionsError> {
822        self.page_range()?;
823        self.document_timeout()?;
824        self.ipynb_outputs()?;
825        self.metadata_output()?;
826        if let Some(s) = self.ocr_scale {
827            if !(s.is_finite() && s > 0.0) {
828                return Err(OptionsError::new(
829                    "ocr_scale",
830                    format!("ocr_scale must be a positive number, got {s}"),
831                ));
832            }
833        }
834        if let Some(s) = self.picture_ocr_scale {
835            if !(s.is_finite() && s > 0.0) {
836                return Err(OptionsError::new(
837                    "picture_ocr_scale",
838                    format!("picture_ocr_scale must be a positive number, got {s}"),
839                ));
840            }
841        }
842        self.picture_description_mode()?;
843        if self.picture_description_concurrency == Some(0) {
844            return Err(OptionsError::new(
845                "picture_description_concurrency",
846                "picture_description_concurrency must be a positive integer, got 0",
847            ));
848        }
849        if let Some(s) = self.images_scale {
850            if !(0.1..=4.0).contains(&s) {
851                return Err(OptionsError::new(
852                    "images_scale",
853                    format!("images_scale must be a number in 0.1-4.0, got {s}"),
854                ));
855            }
856        }
857        self.image_sources()?;
858        if self.max_image_bytes == Some(0) {
859            return Err(OptionsError::new(
860                "max_image_bytes",
861                "max_image_bytes must be a positive number of bytes, got 0",
862            ));
863        }
864        if let Some(t) = self.video_scene_threshold {
865            if !(t.is_finite() && (0.0..=1.0).contains(&t)) {
866                return Err(OptionsError::new(
867                    "video_scene_threshold",
868                    format!("video_scene_threshold must be a number in 0.0-1.0, got {t}"),
869                ));
870            }
871        }
872        if let Some(d) = self.video_frame_dedupe {
873            if d > 64 {
874                return Err(OptionsError::new(
875                    "video_frame_dedupe",
876                    format!("video_frame_dedupe must be a Hamming distance in 0-64, got {d}"),
877                ));
878            }
879        }
880        self.pipeline()?;
881        if self.vlm_max_tokens == Some(0) {
882            return Err(OptionsError::new(
883                "vlm_max_tokens",
884                "vlm_max_tokens must be a positive integer, got 0",
885            ));
886        }
887        #[cfg(feature = "pdf")]
888        {
889            self.ocr_engine()?;
890            self.ocr_mode()?;
891            self.ocr_lang()?;
892        }
893        self.picture_ocr_classes()?;
894        self.redaction()?;
895        Ok(())
896    }
897
898    /// The parsed `picture_ocr_classes` (#645): the comma-separated labels,
899    /// trimmed and lower-cased, empty when unset. With the `pdf` feature
900    /// (which has the classifier's label table) a label the
901    /// DocumentFigureClassifier never predicts is rejected — a typo would
902    /// otherwise silently skip every picture.
903    pub fn picture_ocr_classes(&self) -> Result<Vec<String>, OptionsError> {
904        let labels: Vec<String> = self
905            .picture_ocr_classes
906            .as_deref()
907            .unwrap_or("")
908            .split(',')
909            .map(|s| s.trim().to_ascii_lowercase())
910            .filter(|s| !s.is_empty())
911            .collect();
912        #[cfg(feature = "pdf")]
913        if let Some(bad) = labels
914            .iter()
915            .find(|l| !docling_pdf::enrich::PICTURE_CLASSES.contains(&l.as_str()))
916        {
917            return Err(OptionsError::new(
918                "picture_ocr_classes",
919                format!(
920                    "picture_ocr_classes: {bad:?} is not a DocumentFigureClassifier label ({})",
921                    docling_pdf::enrich::PICTURE_CLASSES.join(", ")
922                ),
923            ));
924        }
925        Ok(labels)
926    }
927
928    /// The parsed `picture_description_mode` (#670): `caption` (the
929    /// default) or `ocr`.
930    pub fn picture_description_mode(&self) -> Result<PictureDescriptionMode, OptionsError> {
931        match self.picture_description_mode.as_deref().map(str::trim) {
932            None | Some("") => Ok(PictureDescriptionMode::Caption),
933            Some(m) => PictureDescriptionMode::parse(m).ok_or_else(|| {
934                OptionsError::new(
935                    "picture_description_mode",
936                    format!("picture_description_mode must be caption or ocr, got {m:?}"),
937                )
938            }),
939        }
940    }
941
942    /// The picture-description pass's resolved options (#670): `None`
943    /// unless `do_picture_description` is set; else the endpoint, model and
944    /// key from the `picture_description_*` options, the `vlm_*` options,
945    /// then the `DOCLING_RS_VLM_*` environment — endpoint and model being
946    /// required — with the prompt, mode and concurrency given here.
947    #[cfg(feature = "vlm")]
948    pub fn picture_description_options(
949        &self,
950    ) -> Result<Option<crate::vlm::PictureDescriptionOptions>, OptionsError> {
951        if self.do_picture_description != Some(true) {
952            return Ok(None);
953        }
954        let set = |s: &Option<String>| s.clone().filter(|v| !v.trim().is_empty());
955        let endpoint = set(&self.picture_description_endpoint).or_else(|| set(&self.vlm_endpoint));
956        let model = set(&self.picture_description_model).or_else(|| set(&self.vlm_model));
957        let mut o =
958            crate::vlm::PictureDescriptionOptions::resolve(endpoint, model).map_err(|e| {
959                let msg = match e {
960                    crate::ConversionError::Parse(m) => m,
961                    other => other.to_string(),
962                }
963                .replace(
964                    "pass --picture-description-endpoint",
965                    "set picture_description_endpoint",
966                )
967                .replace(
968                    "pass --picture-description-model",
969                    "set picture_description_model",
970                );
971                let field = if msg.contains("picture_description_model") {
972                    "picture_description_model"
973                } else {
974                    "picture_description_endpoint"
975                };
976                OptionsError::new(field, msg)
977            })?;
978        if let Some(k) = set(&self.picture_description_api_key).or_else(|| set(&self.vlm_api_key)) {
979            o.api_key = Some(k);
980        }
981        o.prompt = set(&self.picture_description_prompt);
982        o.mode = self.picture_description_mode()?;
983        if let Some(n) = self.picture_description_concurrency {
984            o.concurrency = n;
985        }
986        Ok(Some(o))
987    }
988
989    /// The parsed PII redaction request (#621): `None` unless `redact_pii`
990    /// is set; the mode, kinds, patterns and image mode checked — an
991    /// unknown spelling or an invalid regex is rejected here, naming the
992    /// field, rather than failing the conversion.
993    pub fn redaction(&self) -> Result<Option<RedactionOptions>, OptionsError> {
994        let mut opts = RedactionOptions::default();
995        if let Some(m) = self.redact_mode.as_deref().filter(|m| !m.trim().is_empty()) {
996            opts.replacement = Replacement::parse(m).ok_or_else(|| {
997                OptionsError::new(
998                    "redact_mode",
999                    format!(
1000                        "redact_mode must be one of {} , got {m:?}",
1001                        Replacement::ACCEPTED
1002                    ),
1003                )
1004            })?;
1005        }
1006        if let Some(k) = self.redact_kinds.as_deref() {
1007            for name in k.split(',').map(str::trim).filter(|s| !s.is_empty()) {
1008                let kind = PiiKind::parse(name).ok_or_else(|| {
1009                    OptionsError::new(
1010                        "redact_kinds",
1011                        format!(
1012                            "redact_kinds: {name:?} is not a PII kind ({})",
1013                            PiiKind::ACCEPTED
1014                        ),
1015                    )
1016                })?;
1017                if !opts.kinds.contains(&kind) {
1018                    opts.kinds.push(kind);
1019                }
1020            }
1021        }
1022        if let Some(p) = self.redact_pattern.as_deref() {
1023            opts.custom_patterns = p.lines().filter_map(CustomPattern::parse).collect();
1024            // Compile now: the error names the field.
1025            docling_core::redact::PatternDetector::new(&opts)
1026                .map_err(|e| OptionsError::new("redact_pattern", e.0))?;
1027        }
1028        let list = |s: &Option<String>| -> Vec<String> {
1029            s.as_deref()
1030                .unwrap_or("")
1031                .split(',')
1032                .map(str::trim)
1033                .filter(|t| !t.is_empty())
1034                .map(str::to_string)
1035                .collect()
1036        };
1037        opts.allow_terms = list(&self.redact_allow_terms);
1038        opts.deny_terms = list(&self.redact_deny_terms);
1039        if let Some(p) = self.redact_allow_patterns.as_deref() {
1040            opts.allow_patterns = p
1041                .lines()
1042                .map(str::trim)
1043                .filter(|l| !l.is_empty())
1044                .map(str::to_string)
1045                .collect();
1046            docling_core::redact::AllowList::new(&opts).map_err(|e| {
1047                OptionsError::new(
1048                    "redact_allow_patterns",
1049                    format!("redact_allow_patterns: {}", e.0),
1050                )
1051            })?;
1052        }
1053        if let Some(score) = self.redact_ner_min_score {
1054            if !(score.is_finite() && (0.0..=1.0).contains(&score)) {
1055                return Err(OptionsError::new(
1056                    "redact_ner_min_score",
1057                    format!("redact_ner_min_score must be a number in 0.0-1.0, got {score}"),
1058                ));
1059            }
1060            opts.ner_min_score = score;
1061        }
1062        if let Some(i) = self
1063            .redact_images
1064            .as_deref()
1065            .filter(|i| !i.trim().is_empty())
1066        {
1067            opts.images = ImageRedaction::parse(i).ok_or_else(|| {
1068                OptionsError::new(
1069                    "redact_images",
1070                    format!(
1071                        "redact_images must be one of {}, got {i:?}",
1072                        ImageRedaction::ACCEPTED
1073                    ),
1074                )
1075            })?;
1076        }
1077        Ok(self.redact_pii.unwrap_or(false).then_some(opts))
1078    }
1079
1080    /// Validate, then set every given option on `base` — unset ones leave the
1081    /// base's values (so a surface may pre-configure defaults of its own,
1082    /// e.g. docling-serve's `--strict`). [`DocumentConverter::from_options`]
1083    /// is this on a fresh converter.
1084    pub fn apply(&self, mut c: DocumentConverter) -> Result<DocumentConverter, OptionsError> {
1085        self.validate()?;
1086        if let Some(v) = self.strict {
1087            c = c.strict(v);
1088        }
1089        if let Some(v) = self.compact_tables {
1090            c = c.compact_tables(v);
1091        }
1092        if let Some(v) = self.picture_alt_text {
1093            c = c.picture_alt_text(v);
1094        }
1095        if self.page_break_placeholder.is_some() {
1096            c = c.page_break_placeholder(self.page_break_placeholder.clone());
1097        }
1098        if let Some(v) = self.fetch_images {
1099            c = c.fetch_images(v);
1100        }
1101        if let Some(sources) = self.image_sources()? {
1102            c = c.image_sources(sources);
1103        }
1104        if let Some(hosts) = &self.image_hosts {
1105            c = c.image_hosts(hosts.iter().cloned());
1106        }
1107        if let Some(v) = self.max_image_bytes {
1108            c = c.max_image_bytes(v);
1109        }
1110        if let Some(v) = self.max_images {
1111            c = c.max_images(v);
1112        }
1113        if let Some(v) = self.max_image_total_mb {
1114            c = c.max_image_total_mb(v);
1115        }
1116        if let Some(v) = self.min_image_bytes {
1117            c = c.min_image_bytes(v);
1118        }
1119        if let Some(v) = self.list_attachments {
1120            c = c.list_attachments(v);
1121        }
1122        if let Some(v) = self.skip_empty_cells {
1123            c = c.skip_empty_cells(v);
1124        }
1125        if self.ebcdic_layout.is_some() {
1126            c = c.ebcdic_layout_opt(self.ebcdic_layout.clone());
1127        }
1128        if self.encoding.is_some() {
1129            c = c.encoding(self.encoding.clone());
1130        }
1131        if let Some(outputs) = self.ipynb_outputs()? {
1132            c = c.ipynb_outputs(outputs);
1133        }
1134        if let Some(mode) = self.metadata_output()? {
1135            c = c.metadata_output(mode);
1136        }
1137        if let Some(v) = self.use_web_browser {
1138            c = c.use_web_browser(v);
1139        }
1140        if let Some(dir) = &self.xbrl_taxonomy {
1141            c = c.xbrl_taxonomy(dir);
1142        }
1143        if self.asr_model.is_some() {
1144            c = c.asr_model(self.asr_model.clone());
1145        }
1146        if self.asr_lang.is_some() {
1147            c = c.asr_lang(self.asr_lang.clone());
1148        }
1149        if let Some(n) = self.video_frames {
1150            c = c.video_frames(n);
1151        }
1152        if let Some(t) = self.video_scene_threshold {
1153            c = c.video_scene_threshold(t);
1154        }
1155        if let Some(px) = self.video_frame_max_side {
1156            c = c.video_frame_max_side(px);
1157        }
1158        if let Some(d) = self.video_frame_dedupe {
1159            c = c.video_frame_dedupe(Some(d));
1160        }
1161        if let Some((first, last)) = self.page_range()? {
1162            c = c.page_range(first, last);
1163        }
1164        if self.document_timeout.is_some() {
1165            c = c.document_timeout(self.document_timeout()?);
1166        }
1167        if let Some(v) = self.text_layer_only {
1168            c = c.text_layer_only(v);
1169        }
1170        if self.no_ocr.is_some() || self.skip_ocr.is_some() {
1171            c = c.no_ocr(self.ocr_disabled());
1172        }
1173        if self.password.is_some() {
1174            c = c.password(self.password.clone());
1175        }
1176        if let Some(v) = self.force_full_page_ocr {
1177            c = c.force_full_page_ocr(v);
1178        }
1179        if let Some(v) = self.no_table_former {
1180            c = c.no_table_former(v);
1181        }
1182        if let Some(v) = self.no_text_panels {
1183            c = c.no_text_panels(v);
1184        }
1185        if let Some(v) = self.heading_hierarchy {
1186            c = c.heading_hierarchy(v);
1187        }
1188        if let Some(v) = &self.ocr_engine {
1189            c = c.ocr_engine(v.clone());
1190        }
1191        if let Some(v) = &self.ocr_lang {
1192            c = c.ocr_lang(v.clone());
1193        }
1194        if let Some(v) = &self.ocr_mode {
1195            c = c.ocr_mode(v.clone());
1196        }
1197        if let Some(s) = self.ocr_scale {
1198            c = c.ocr_scale(s);
1199        }
1200        if let Some(v) = self.ocr_textline_orientation {
1201            c = c.ocr_textline_orientation(v);
1202        }
1203        if let Some(s) = self.images_scale {
1204            c = c.images_scale(s);
1205        }
1206        if let Some(v) = self.page_images {
1207            c = c.generate_page_images(v);
1208        }
1209        if let Some(v) = self.do_picture_classification {
1210            c = c.do_picture_classification(v);
1211        }
1212        if let Some(v) = self.do_code_enrichment {
1213            c = c.do_code_enrichment(v);
1214        }
1215        if let Some(v) = self.do_formula_enrichment {
1216            c = c.do_formula_enrichment(v);
1217        }
1218        if let Some(v) = self.do_picture_ocr {
1219            c = c.do_picture_ocr(v);
1220        }
1221        if self.picture_ocr_classes.is_some() {
1222            c = c.picture_ocr_classes(self.picture_ocr_classes()?);
1223        }
1224        if let Some(v) = self.picture_ocr_min_side {
1225            c = c.picture_ocr_min_side(v);
1226        }
1227        if let Some(s) = self.picture_ocr_scale {
1228            c = c.picture_ocr_scale(s);
1229        }
1230        if let Some(v) = self.keep_picture_images {
1231            c = c.keep_picture_images(v);
1232        }
1233        #[cfg(feature = "vlm")]
1234        if let Some(o) = self.picture_description_options()? {
1235            c = c.picture_description(o);
1236        }
1237        if self.redact_pii.is_some() {
1238            c = c.redact_pii_opt(self.redaction()?);
1239        }
1240        Ok(c)
1241    }
1242
1243    // --- typed readers, for the surfaces that drive a warm `Pipeline`
1244    //     themselves and need the parsed values rather than the builder -----
1245
1246    /// `pages` parsed ([`crate::parse_page_range`]); `None` when unset.
1247    pub fn page_range(&self) -> Result<Option<(usize, usize)>, OptionsError> {
1248        self.pages
1249            .as_deref()
1250            .map(|p| {
1251                crate::parse_page_range(p)
1252                    .map_err(|e| OptionsError::new("pages", format!("pages: {e}")))
1253            })
1254            .transpose()
1255    }
1256
1257    /// `document_timeout` as a duration; `None` when unset.
1258    pub fn document_timeout(&self) -> Result<Option<std::time::Duration>, OptionsError> {
1259        match self.document_timeout {
1260            Some(s) if s.is_finite() && s > 0.0 => Ok(Some(std::time::Duration::from_secs_f64(s))),
1261            Some(s) => Err(OptionsError::new(
1262                "document_timeout",
1263                format!("document_timeout must be a positive number of seconds, got {s}"),
1264            )),
1265            None => Ok(None),
1266        }
1267    }
1268
1269    /// The pipeline the options select.
1270    pub fn pipeline(&self) -> Result<PipelineKind, OptionsError> {
1271        match self.pipeline.as_deref().map(str::trim) {
1272            None | Some("standard") => Ok(PipelineKind::Standard),
1273            Some("vlm") => Ok(PipelineKind::Vlm),
1274            Some(other) => Err(OptionsError::new(
1275                "pipeline",
1276                format!("unknown pipeline {other:?} (expected: standard, vlm)"),
1277            )),
1278        }
1279    }
1280
1281    /// The enrichment passes (#423) — unset and `false` both mean off.
1282    pub fn enrichments(&self) -> crate::EnrichmentOptions {
1283        crate::EnrichmentOptions {
1284            picture_classification: self.do_picture_classification.unwrap_or(false),
1285            code: self.do_code_enrichment.unwrap_or(false),
1286            formula: self.do_formula_enrichment.unwrap_or(false),
1287        }
1288    }
1289
1290    /// `ocr_engine` parsed; `None` when unset (the process default applies).
1291    #[cfg(feature = "pdf")]
1292    pub fn ocr_engine(&self) -> Result<Option<crate::OcrEngine>, OptionsError> {
1293        self.ocr_engine
1294            .as_deref()
1295            .map(|v| {
1296                crate::OcrEngine::parse(v).ok_or_else(|| {
1297                    OptionsError::new(
1298                        "ocr_engine",
1299                        format!("ocr_engine {v:?} is not {}", crate::OcrEngine::ACCEPTED),
1300                    )
1301                })
1302            })
1303            .transpose()
1304    }
1305
1306    /// The engine `ocr_lang` is read against: `ocr_engine`, else the process
1307    /// default (`DOCLING_RS_OCR_ENGINE`, else PP-OCR).
1308    #[cfg(feature = "pdf")]
1309    pub fn effective_ocr_engine(&self) -> Result<crate::OcrEngine, OptionsError> {
1310        Ok(self
1311            .ocr_engine()?
1312            .unwrap_or_else(crate::OcrEngine::from_env))
1313    }
1314
1315    /// `ocr_lang` validated against the engine (#460) and, under PP-OCR, the
1316    /// recognition model it selects; `None` when unset or under Tesseract
1317    /// (whose language list is [`Self::tesseract_lang`]).
1318    #[cfg(feature = "pdf")]
1319    pub fn ocr_lang(&self) -> Result<Option<crate::OcrLang>, OptionsError> {
1320        let Some(v) = self.ocr_lang.as_deref() else {
1321            return Ok(None);
1322        };
1323        let engine = self.effective_ocr_engine()?;
1324        engine.validate_lang(v).map_err(|e| {
1325            OptionsError::new(
1326                "ocr_lang",
1327                if e.contains("ocr_lang") {
1328                    e
1329                } else {
1330                    format!("ocr_lang: {e}")
1331                },
1332            )
1333        })?;
1334        Ok(match engine {
1335            crate::OcrEngine::PpOcr => crate::OcrLang::parse(v),
1336            crate::OcrEngine::Tesseract => None,
1337        })
1338    }
1339
1340    /// Tesseract's `-l` argument from `ocr_lang` (#460); `None` under PP-OCR
1341    /// or without a language.
1342    #[cfg(feature = "pdf")]
1343    pub fn tesseract_lang(&self) -> Result<Option<String>, OptionsError> {
1344        let Some(v) = self.ocr_lang.as_deref() else {
1345            return Ok(None);
1346        };
1347        match self.effective_ocr_engine()? {
1348            crate::OcrEngine::Tesseract => crate::tesseract_lang_arg(v)
1349                .map(Some)
1350                .map_err(|e| OptionsError::new("ocr_lang", e)),
1351            crate::OcrEngine::PpOcr => Ok(None),
1352        }
1353    }
1354
1355    /// Whether OCR is off: `no_ocr` or its pre-2.0 name `skip_ocr` (#611).
1356    /// What a surface that builds the PDF pipeline itself reads.
1357    pub fn ocr_disabled(&self) -> bool {
1358        self.no_ocr.unwrap_or(false) || self.skip_ocr.unwrap_or(false)
1359    }
1360
1361    /// `ocr_mode` parsed (#254); `None` when unset.
1362    #[cfg(feature = "pdf")]
1363    pub fn ocr_mode(&self) -> Result<Option<crate::OcrMode>, OptionsError> {
1364        self.ocr_mode
1365            .as_deref()
1366            .map(|v| {
1367                crate::OcrMode::parse(v).ok_or_else(|| {
1368                    OptionsError::new(
1369                        "ocr_mode",
1370                        format!(
1371                            "ocr_mode {v:?} is not \
1372                             default|full_page|layout_regions|pdf_aware_layout_regions"
1373                        ),
1374                    )
1375                })
1376            })
1377            .transpose()
1378    }
1379
1380    /// Picture-crop scale and page images for the pipeline (#519/#520).
1381    #[cfg(feature = "pdf")]
1382    pub fn image_output(&self) -> crate::ImageOutput {
1383        crate::ImageOutput {
1384            scale: self.images_scale,
1385            page_images: self.page_images.unwrap_or(false),
1386        }
1387    }
1388
1389    /// The remote-VLM configuration (#77) when `pipeline` is `vlm`, resolved
1390    /// against the `DOCLING_RS_VLM_*` environment the way every surface did
1391    /// on its own before: endpoint and model from the options else the env
1392    /// (blank counts as unset), the prompt and API key likewise, `pages`
1393    /// composing like it does with the ML pipeline. `None` under the standard
1394    /// pipeline — the `vlm_*` options are then ignored, not rejected (the
1395    /// bindings' long-standing contract).
1396    #[cfg(feature = "vlm")]
1397    pub fn vlm_options(&self) -> Result<Option<crate::vlm::VlmOptions>, OptionsError> {
1398        if self.pipeline()? != PipelineKind::Vlm {
1399            return Ok(None);
1400        }
1401        let set = |s: &Option<String>| s.clone().filter(|v| !v.trim().is_empty());
1402        let mut v = crate::vlm::VlmOptions::resolve(set(&self.vlm_endpoint), set(&self.vlm_model))
1403            .map_err(|e| {
1404                // The library's message names the CLI flags; the wire spelling
1405                // is this surface's (the CLI swaps its flags back in).
1406                let msg = match e {
1407                    crate::ConversionError::Parse(m) => m,
1408                    other => other.to_string(),
1409                }
1410                .replace("pass --vlm-endpoint", "set vlm_endpoint")
1411                .replace("pass --vlm-model", "set vlm_model");
1412                let field = if msg.contains("vlm_model") {
1413                    "vlm_model"
1414                } else {
1415                    "vlm_endpoint"
1416                };
1417                OptionsError::new(field, msg)
1418            })?;
1419        if let Some(p) = set(&self.vlm_prompt) {
1420            v.prompt = Some(p);
1421        }
1422        if let Some(k) = set(&self.vlm_api_key) {
1423            v.api_key = Some(k);
1424        }
1425        match self.vlm_max_tokens {
1426            // 0 would have every page come back empty and surface as a model
1427            // error — a message pointing at the model, not at the option.
1428            Some(0) => {
1429                return Err(OptionsError::new(
1430                    "vlm_max_tokens",
1431                    "vlm_max_tokens must be a positive integer, got 0",
1432                ))
1433            }
1434            Some(n) => v.max_tokens = n,
1435            None => {}
1436        }
1437        v.page_range = self.page_range()?;
1438        Ok(Some(v))
1439    }
1440}
1441
1442impl DocumentConverter {
1443    /// A converter configured from [`ConvertOptions`] — validated and applied
1444    /// onto the defaults ([`ConvertOptions::apply`] on [`Self::new`]).
1445    pub fn from_options(options: &ConvertOptions) -> Result<Self, OptionsError> {
1446        options.apply(Self::new())
1447    }
1448}
1449
1450#[cfg(test)]
1451mod tests {
1452    use super::*;
1453
1454    /// The spelling table and the struct describe the same fields, both ways
1455    /// — the guard that makes "add a field" also mean "add its row".
1456    #[test]
1457    fn options_table_matches_the_struct() {
1458        let fields = ConvertOptions::field_names();
1459        for o in OPTIONS {
1460            assert!(
1461                fields.iter().any(|f| f == o.name),
1462                "no field for row {}",
1463                o.name
1464            );
1465            for a in o.aliases {
1466                assert!(!fields.iter().any(|f| f == a), "alias {a} is also a field");
1467            }
1468        }
1469        for f in &fields {
1470            assert!(
1471                OPTIONS.iter().any(|o| o.name == f),
1472                "no OPTIONS row for field {f}"
1473            );
1474        }
1475        assert_eq!(cli_flag("ocr_scale"), "--ocr-scale");
1476        assert_eq!(cli_flag("do_code_enrichment"), "--enrich-code");
1477    }
1478
1479    /// Text values read as the option's type (#679's follow-up: the MCP
1480    /// form); a typed value for a text option as its text; the rest as is.
1481    #[test]
1482    fn text_values_read_as_the_options_type() {
1483        let mut o = ConvertOptions::default();
1484        assert!(o.set_text("strict", "true").unwrap());
1485        assert!(o.set_text("compact_tables", "YES").unwrap());
1486        assert!(o.set_text("ocr_scale", "1.5").unwrap());
1487        assert!(o.set_text("video_frames", "4").unwrap());
1488        assert!(o.set_text("pages", "5").unwrap());
1489        assert!(o.set_text("pdf_password", "0").unwrap(), "an alias");
1490        assert!(!o.set_text("not_an_option", "x").unwrap());
1491        assert_eq!(o.strict, Some(true));
1492        assert_eq!(o.compact_tables, Some(true));
1493        assert_eq!(o.ocr_scale, Some(1.5));
1494        assert_eq!(o.video_frames, Some(4));
1495        assert_eq!(o.pages.as_deref(), Some("5"));
1496        assert_eq!(o.password.as_deref(), Some("0"));
1497        let e = o.set_text("strict", "maybe").unwrap_err();
1498        assert_eq!(e.field, "strict");
1499        assert_eq!(
1500            e.cli_message(),
1501            "--strict: cannot read \"maybe\" as that option's value"
1502        );
1503
1504        let o = ConvertOptions::from_loose_json(
1505            serde_json::json!({
1506                "force_full_page_ocr": "true",
1507                "ocr_scale": "2",
1508                "pages": 3,
1509                "strict": 1,
1510                "text_layer_only": false,
1511                "ocr_lang": null,
1512                "redact_kinds": "email, phone"
1513            })
1514            .as_object()
1515            .unwrap()
1516            .clone(),
1517        )
1518        .unwrap();
1519        assert_eq!(o.force_full_page_ocr, Some(true));
1520        assert_eq!(o.ocr_scale, Some(2.0));
1521        assert_eq!(o.pages.as_deref(), Some("3"));
1522        assert_eq!(o.strict, Some(true));
1523        assert_eq!(o.text_layer_only, Some(false));
1524        assert_eq!(o.ocr_lang, None);
1525        assert_eq!(o.redact_kinds.as_deref(), Some("email, phone"));
1526        let bad = |v: serde_json::Value| {
1527            ConvertOptions::from_loose_json(v.as_object().unwrap().clone())
1528                .unwrap_err()
1529                .to_string()
1530        };
1531        assert!(bad(serde_json::json!({"strict": "maybe"})).contains("strict: cannot read"));
1532        assert!(bad(serde_json::json!({"strict": [1]})).contains("strict: invalid type"));
1533        assert!(bad(serde_json::json!({"strictt": true})).contains("unknown field `strictt`"));
1534    }
1535
1536    #[test]
1537    fn aliases_deserialize_and_unknown_keys_are_reported() {
1538        let o: ConvertOptions = serde_json::from_str(
1539            r#"{"md_page_break_placeholder": "<!-- p -->", "generate_page_images": true}"#,
1540        )
1541        .unwrap();
1542        assert_eq!(o.page_break_placeholder.as_deref(), Some("<!-- p -->"));
1543        assert_eq!(o.page_images, Some(true));
1544        assert_eq!(
1545            ConvertOptions::unknown_keys(
1546                ["to", "strict", "md_page_break_placeholder", "strictness"],
1547                &["to"]
1548            ),
1549            vec!["strictness".to_string()]
1550        );
1551    }
1552
1553    /// #647: `video_frames` reads an integer, `"all"`, a digit string (the
1554    /// text surfaces) and a 32-bit-max count as the npm addon sends it;
1555    /// the two bounded tunables are range-checked.
1556    #[test]
1557    fn video_frames_spellings_and_video_ranges() {
1558        let parse = |json: &str| serde_json::from_str::<ConvertOptions>(json).unwrap();
1559        assert_eq!(parse(r#"{"video_frames": 3}"#).video_frames, Some(3));
1560        assert_eq!(parse(r#"{"video_frames": "12"}"#).video_frames, Some(12));
1561        assert_eq!(
1562            parse(r#"{"video_frames": "all"}"#).video_frames,
1563            Some(crate::ALL_VIDEO_FRAMES)
1564        );
1565        assert_eq!(
1566            parse(r#"{"video_frames": 4294967295}"#).video_frames,
1567            Some(crate::ALL_VIDEO_FRAMES)
1568        );
1569        assert_eq!(parse("{}").video_frames, None);
1570        assert!(serde_json::from_str::<ConvertOptions>(r#"{"video_frames": "some"}"#).is_err());
1571        // "all" survives a merge round trip (serialized as the u64 max).
1572        let merged = ConvertOptions {
1573            video_frames: Some(crate::ALL_VIDEO_FRAMES),
1574            ..Default::default()
1575        }
1576        .merge_over(ConvertOptions::default());
1577        assert_eq!(merged.video_frames, Some(crate::ALL_VIDEO_FRAMES));
1578
1579        let ok = ConvertOptions {
1580            video_scene_threshold: Some(0.6),
1581            video_frame_max_side: Some(640),
1582            video_frame_dedupe: Some(5),
1583            ..Default::default()
1584        };
1585        ok.validate().unwrap();
1586        let bad = ConvertOptions {
1587            video_scene_threshold: Some(1.5),
1588            ..Default::default()
1589        };
1590        assert_eq!(bad.validate().unwrap_err().field, "video_scene_threshold");
1591        let bad = ConvertOptions {
1592            video_frame_dedupe: Some(65),
1593            ..Default::default()
1594        };
1595        assert_eq!(bad.validate().unwrap_err().field, "video_frame_dedupe");
1596    }
1597
1598    #[test]
1599    fn merge_prefers_the_overlay_and_keeps_the_rest() {
1600        let base = ConvertOptions {
1601            strict: Some(true),
1602            ocr_scale: Some(2.0),
1603            pages: Some("1-3".into()),
1604            ..Default::default()
1605        };
1606        let over = ConvertOptions {
1607            strict: Some(false),
1608            video_frames: Some(0),
1609            ..Default::default()
1610        };
1611        let merged = over.merge_over(base);
1612        assert_eq!(merged.strict, Some(false));
1613        assert_eq!(merged.ocr_scale, Some(2.0));
1614        assert_eq!(merged.pages.as_deref(), Some("1-3"));
1615        assert_eq!(merged.video_frames, Some(0));
1616    }
1617
1618    #[test]
1619    fn validation_rules_and_cli_spelling() {
1620        let bad = |o: ConvertOptions| o.validate().unwrap_err();
1621        let e = bad(ConvertOptions {
1622            ocr_scale: Some(0.0),
1623            ..Default::default()
1624        });
1625        assert_eq!(e.field, "ocr_scale");
1626        assert_eq!(e.to_string(), "ocr_scale must be a positive number, got 0");
1627        assert_eq!(
1628            e.cli_message(),
1629            "--ocr-scale must be a positive number, got 0"
1630        );
1631        // #666: the picture OCR's own scale, the same rule.
1632        let e = bad(ConvertOptions {
1633            picture_ocr_scale: Some(-1.0),
1634            ..Default::default()
1635        });
1636        assert_eq!(
1637            e.cli_message(),
1638            "--picture-ocr-scale must be a positive number, got -1"
1639        );
1640
1641        let e = bad(ConvertOptions {
1642            images_scale: Some(9.0),
1643            ..Default::default()
1644        });
1645        assert_eq!(e.field, "images_scale");
1646        let e = bad(ConvertOptions {
1647            document_timeout: Some(-1.0),
1648            ..Default::default()
1649        });
1650        assert!(e.message.contains("document_timeout must be a positive"));
1651        let e = bad(ConvertOptions {
1652            pages: Some("5-3".into()),
1653            ..Default::default()
1654        });
1655        assert_eq!(e.field, "pages");
1656        assert!(
1657            e.cli_message().starts_with("--pages: "),
1658            "{}",
1659            e.cli_message()
1660        );
1661        let e = bad(ConvertOptions {
1662            pipeline: Some("magic".into()),
1663            ..Default::default()
1664        });
1665        assert!(e.message.contains("unknown pipeline"), "{e}");
1666        let e = bad(ConvertOptions {
1667            vlm_max_tokens: Some(0),
1668            ..Default::default()
1669        });
1670        assert_eq!(
1671            e.cli_message(),
1672            "--vlm-max-tokens must be a positive integer, got 0"
1673        );
1674        assert!(ConvertOptions::default().validate().is_ok());
1675    }
1676
1677    #[cfg(feature = "pdf")]
1678    #[test]
1679    fn ocr_options_are_validated_against_the_engine() {
1680        let e = ConvertOptions {
1681            ocr_engine: Some("easyocr".into()),
1682            ..Default::default()
1683        }
1684        .validate()
1685        .unwrap_err();
1686        assert_eq!(e.field, "ocr_engine");
1687        assert!(
1688            e.cli_message().starts_with("--ocr-engine \"easyocr\""),
1689            "{}",
1690            e.cli_message()
1691        );
1692        // `deu` is a language to Tesseract only.
1693        let e = ConvertOptions {
1694            ocr_engine: Some("ppocr".into()),
1695            ocr_lang: Some("deu".into()),
1696            ..Default::default()
1697        }
1698        .validate()
1699        .unwrap_err();
1700        assert_eq!(e.field, "ocr_lang");
1701        assert!(e.message.contains("ocr_lang"), "{e}");
1702        let tess = ConvertOptions {
1703            ocr_engine: Some("tesseract".into()),
1704            ocr_lang: Some("deu+fra".into()),
1705            ..Default::default()
1706        };
1707        assert!(tess.validate().is_ok());
1708        assert_eq!(tess.ocr_lang().unwrap(), None);
1709        assert_eq!(tess.tesseract_lang().unwrap().as_deref(), Some("deu+fra"));
1710        let e = ConvertOptions {
1711            ocr_mode: Some("sideways".into()),
1712            ..Default::default()
1713        }
1714        .validate()
1715        .unwrap_err();
1716        assert_eq!(e.field, "ocr_mode");
1717    }
1718
1719    #[test]
1720    fn apply_sets_only_what_is_given() {
1721        // `strict` and the page-break text ride on the finished document, so
1722        // they are observable without a conversion.
1723        let base = DocumentConverter::new().strict(true);
1724        let mut doc = docling_core::DoclingDocument::new("t");
1725        ConvertOptions::default()
1726            .apply(base.clone())
1727            .unwrap()
1728            .finish_document(&mut doc);
1729        assert!(doc.strict_markdown, "unset keeps the base's strict");
1730        let mut doc = docling_core::DoclingDocument::new("t");
1731        ConvertOptions {
1732            strict: Some(false),
1733            page_break_placeholder: Some("<!-- p -->".into()),
1734            ..Default::default()
1735        }
1736        .apply(base)
1737        .unwrap()
1738        .finish_document(&mut doc);
1739        assert!(!doc.strict_markdown);
1740        assert_eq!(doc.page_break_placeholder.as_deref(), Some("<!-- p -->"));
1741        // A rejected option never yields a converter.
1742        assert!(DocumentConverter::from_options(&ConvertOptions {
1743            pages: Some("0-2".into()),
1744            ..Default::default()
1745        })
1746        .is_err());
1747    }
1748
1749    #[cfg(feature = "vlm")]
1750    #[test]
1751    fn vlm_options_follow_the_pipeline_switch() {
1752        // Standard: stray vlm_* options are ignored, nothing resolves.
1753        let stray = ConvertOptions {
1754            vlm_endpoint: Some("http://127.0.0.1:9/v1".into()),
1755            ..Default::default()
1756        };
1757        assert!(matches!(stray.vlm_options(), Ok(None)));
1758        let full = ConvertOptions {
1759            pipeline: Some("vlm".into()),
1760            vlm_endpoint: Some("http://example.com/v1".into()),
1761            vlm_model: Some("m".into()),
1762            vlm_api_key: Some("sk-test".into()),
1763            vlm_prompt: Some("Read the page.".into()),
1764            vlm_max_tokens: Some(512),
1765            pages: Some("2-5".into()),
1766            ..Default::default()
1767        };
1768        let v = full.vlm_options().unwrap().expect("resolves");
1769        assert_eq!(v.endpoint, "http://example.com/v1");
1770        assert_eq!(v.model, "m");
1771        assert_eq!(v.api_key.as_deref(), Some("sk-test"));
1772        assert_eq!(v.prompt.as_deref(), Some("Read the page."));
1773        assert_eq!(v.max_tokens, 512);
1774        assert_eq!(v.page_range, Some((2, 5)));
1775        let e = ConvertOptions {
1776            vlm_max_tokens: Some(0),
1777            ..full.clone()
1778        }
1779        .vlm_options()
1780        .unwrap_err();
1781        assert_eq!(e.field, "vlm_max_tokens");
1782        // No model anywhere: the message names the wire option, the CLI the
1783        // flag. (`DOCLING_RS_VLM_MODEL` unset in the test environment.)
1784        if std::env::var_os("DOCLING_RS_VLM_MODEL").is_none() {
1785            let e = ConvertOptions {
1786                vlm_model: None,
1787                ..full
1788            }
1789            .vlm_options()
1790            .unwrap_err();
1791            assert_eq!(e.field, "vlm_model");
1792            assert!(e.message.contains("set vlm_model"), "{e}");
1793            assert!(
1794                e.cli_message().contains("--vlm-model"),
1795                "{}",
1796                e.cli_message()
1797            );
1798            assert!(e.message.contains("DOCLING_RS_VLM_MODEL"), "{e}");
1799        }
1800    }
1801
1802    /// #611: `no_ocr` is docling's `do_ocr=False` and `skip_ocr` its pre-2.0
1803    /// name — either one turns OCR off, and a body carrying both (a pre-2.0
1804    /// client sending both switches) parses instead of failing on a
1805    /// duplicate field; `text_layer_only` and `password` — under docling's
1806    /// `pdf_password` too (#625) — are read.
1807    #[test]
1808    fn ocr_spellings_and_password_parse() {
1809        let both: ConvertOptions = serde_json::from_str(
1810            r#"{"no_ocr": false, "skip_ocr": true, "text_layer_only": true,
1811                "pdf_password": "1234"}"#,
1812        )
1813        .expect("both spellings parse");
1814        assert!(both.ocr_disabled());
1815        assert_eq!(both.text_layer_only, Some(true));
1816        assert_eq!(both.password.as_deref(), Some("1234"));
1817        let neutral: ConvertOptions = serde_json::from_str(r#"{"password": "x"}"#).unwrap();
1818        assert_eq!(neutral.password.as_deref(), Some("x"));
1819        let new: ConvertOptions = serde_json::from_str(r#"{"no_ocr": true}"#).unwrap();
1820        assert!(new.ocr_disabled());
1821        assert!(!ConvertOptions::default().ocr_disabled());
1822        // Merged like every option: a later layer's spelling adds to the base.
1823        let merged = new.merge_over(both);
1824        assert!(merged.ocr_disabled());
1825        assert_eq!(merged.password.as_deref(), Some("1234"));
1826    }
1827}