use std::fmt;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use crate::DocumentConverter;
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ConvertOptions {
pub strict: Option<bool>,
pub compact_tables: Option<bool>,
#[serde(alias = "md_page_break_placeholder")]
pub page_break_placeholder: Option<String>,
pub fetch_images: Option<bool>,
pub list_attachments: Option<bool>,
pub skip_empty_cells: Option<bool>,
pub ebcdic_layout: Option<String>,
pub encoding: Option<String>,
pub use_web_browser: Option<bool>,
pub xbrl_taxonomy: Option<String>,
pub asr_model: Option<String>,
pub asr_lang: Option<String>,
pub video_frames: Option<usize>,
pub pages: Option<String>,
pub document_timeout: Option<f64>,
pub text_layer_only: Option<bool>,
pub no_ocr: Option<bool>,
pub skip_ocr: Option<bool>,
pub pdf_password: Option<String>,
pub force_full_page_ocr: Option<bool>,
pub no_table_former: Option<bool>,
pub no_text_panels: Option<bool>,
pub heading_hierarchy: Option<bool>,
pub ocr_engine: Option<String>,
pub ocr_lang: Option<String>,
pub ocr_mode: Option<String>,
pub ocr_scale: Option<f32>,
pub images_scale: Option<f32>,
#[serde(alias = "generate_page_images")]
pub page_images: Option<bool>,
pub do_picture_classification: Option<bool>,
pub do_code_enrichment: Option<bool>,
pub do_formula_enrichment: Option<bool>,
pub pipeline: Option<String>,
pub vlm_endpoint: Option<String>,
pub vlm_model: Option<String>,
pub vlm_api_key: Option<String>,
pub vlm_prompt: Option<String>,
pub vlm_max_tokens: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OptionsError {
pub field: &'static str,
pub message: String,
}
impl OptionsError {
fn new(field: &'static str, message: impl Into<String>) -> Self {
Self {
field,
message: message.into(),
}
}
pub fn cli_message(&self) -> String {
let flag = cli_flag(self.field);
if self.message.contains(self.field) {
self.message.replacen(self.field, &flag, 1)
} else {
format!("{flag}: {}", self.message)
}
}
}
impl fmt::Display for OptionsError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for OptionsError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PipelineKind {
Standard,
Vlm,
}
#[derive(Debug, Clone, Copy)]
pub struct OptionInfo {
pub name: &'static str,
pub aliases: &'static [&'static str],
pub cli: Option<&'static str>,
pub python: Option<&'static str>,
pub node: Option<&'static str>,
}
const fn row(name: &'static str) -> OptionInfo {
OptionInfo {
name,
aliases: &[],
cli: None,
python: None,
node: None,
}
}
pub const OPTIONS: &[OptionInfo] = &[
OptionInfo {
python: Some(""),
..row("strict")
},
row("compact_tables"),
OptionInfo {
aliases: &["md_page_break_placeholder"],
python: Some(""),
..row("page_break_placeholder")
},
row("fetch_images"),
row("list_attachments"),
row("skip_empty_cells"),
row("ebcdic_layout"),
row("encoding"),
OptionInfo {
node: Some(""),
..row("use_web_browser")
},
row("xbrl_taxonomy"),
row("asr_model"),
row("asr_lang"),
row("video_frames"),
OptionInfo {
python: Some("page_range"),
..row("pages")
},
row("document_timeout"),
row("text_layer_only"),
OptionInfo {
python: Some("do_ocr"),
..row("no_ocr")
},
OptionInfo {
python: Some(""),
..row("skip_ocr")
},
row("pdf_password"),
row("force_full_page_ocr"),
OptionInfo {
python: Some("do_table_structure"),
..row("no_table_former")
},
row("no_text_panels"),
row("heading_hierarchy"),
row("ocr_engine"),
row("ocr_lang"),
row("ocr_mode"),
row("ocr_scale"),
row("images_scale"),
OptionInfo {
aliases: &["generate_page_images"],
python: Some("generate_page_images"),
..row("page_images")
},
OptionInfo {
cli: Some("--enrich-picture-classes"),
..row("do_picture_classification")
},
OptionInfo {
cli: Some("--enrich-code"),
..row("do_code_enrichment")
},
OptionInfo {
cli: Some("--enrich-formula"),
..row("do_formula_enrichment")
},
row("pipeline"),
row("vlm_endpoint"),
row("vlm_model"),
row("vlm_api_key"),
row("vlm_prompt"),
row("vlm_max_tokens"),
];
pub fn cli_flag(field: &str) -> String {
OPTIONS
.iter()
.find(|o| o.name == field)
.and_then(|o| o.cli)
.map(str::to_string)
.unwrap_or_else(|| format!("--{}", field.replace('_', "-")))
}
pub fn merge_options<T: Serialize + DeserializeOwned + Default>(over: T, base: T) -> T {
let mut merged = serde_json::to_value(base).unwrap_or(serde_json::Value::Null);
let over = serde_json::to_value(over).unwrap_or(serde_json::Value::Null);
if let (Some(into), Some(from)) = (merged.as_object_mut(), over.as_object()) {
for (key, value) in from {
if !value.is_null() {
into.insert(key.clone(), value.clone());
}
}
}
serde_json::from_value(merged).unwrap_or_default()
}
impl ConvertOptions {
pub fn field_names() -> Vec<String> {
match serde_json::to_value(Self::default()) {
Ok(serde_json::Value::Object(map)) => map.keys().cloned().collect(),
_ => Vec::new(),
}
}
pub fn unknown_keys<'a>(
keys: impl IntoIterator<Item = &'a str>,
also_known: &[&str],
) -> Vec<String> {
let fields = Self::field_names();
keys.into_iter()
.filter(|k| {
!fields.iter().any(|f| f == k)
&& !also_known.contains(k)
&& !OPTIONS.iter().any(|o| o.aliases.contains(k))
})
.map(str::to_string)
.collect()
}
pub fn merge_over(self, base: ConvertOptions) -> ConvertOptions {
merge_options(self, base)
}
pub fn validate(&self) -> Result<(), OptionsError> {
self.page_range()?;
self.document_timeout()?;
if let Some(s) = self.ocr_scale {
if !(s.is_finite() && s > 0.0) {
return Err(OptionsError::new(
"ocr_scale",
format!("ocr_scale must be a positive number, got {s}"),
));
}
}
if let Some(s) = self.images_scale {
if !(0.1..=4.0).contains(&s) {
return Err(OptionsError::new(
"images_scale",
format!("images_scale must be a number in 0.1-4.0, got {s}"),
));
}
}
self.pipeline()?;
if self.vlm_max_tokens == Some(0) {
return Err(OptionsError::new(
"vlm_max_tokens",
"vlm_max_tokens must be a positive integer, got 0",
));
}
#[cfg(feature = "pdf")]
{
self.ocr_engine()?;
self.ocr_mode()?;
self.ocr_lang()?;
}
Ok(())
}
pub fn apply(&self, mut c: DocumentConverter) -> Result<DocumentConverter, OptionsError> {
self.validate()?;
if let Some(v) = self.strict {
c = c.strict(v);
}
if let Some(v) = self.compact_tables {
c = c.compact_tables(v);
}
if self.page_break_placeholder.is_some() {
c = c.page_break_placeholder(self.page_break_placeholder.clone());
}
if let Some(v) = self.fetch_images {
c = c.fetch_images(v);
}
if let Some(v) = self.list_attachments {
c = c.list_attachments(v);
}
if let Some(v) = self.skip_empty_cells {
c = c.skip_empty_cells(v);
}
if self.ebcdic_layout.is_some() {
c = c.ebcdic_layout_opt(self.ebcdic_layout.clone());
}
if self.encoding.is_some() {
c = c.encoding(self.encoding.clone());
}
if let Some(v) = self.use_web_browser {
c = c.use_web_browser(v);
}
if let Some(dir) = &self.xbrl_taxonomy {
c = c.xbrl_taxonomy(dir);
}
if self.asr_model.is_some() {
c = c.asr_model(self.asr_model.clone());
}
if self.asr_lang.is_some() {
c = c.asr_lang(self.asr_lang.clone());
}
if let Some(n) = self.video_frames {
c = c.video_frames(n);
}
if let Some((first, last)) = self.page_range()? {
c = c.page_range(first, last);
}
if self.document_timeout.is_some() {
c = c.document_timeout(self.document_timeout()?);
}
if let Some(v) = self.text_layer_only {
c = c.text_layer_only(v);
}
if self.no_ocr.is_some() || self.skip_ocr.is_some() {
c = c.no_ocr(self.ocr_disabled());
}
if self.pdf_password.is_some() {
c = c.pdf_password(self.pdf_password.clone());
}
if let Some(v) = self.force_full_page_ocr {
c = c.force_full_page_ocr(v);
}
if let Some(v) = self.no_table_former {
c = c.no_table_former(v);
}
if let Some(v) = self.no_text_panels {
c = c.no_text_panels(v);
}
if let Some(v) = self.heading_hierarchy {
c = c.heading_hierarchy(v);
}
if let Some(v) = &self.ocr_engine {
c = c.ocr_engine(v.clone());
}
if let Some(v) = &self.ocr_lang {
c = c.ocr_lang(v.clone());
}
if let Some(v) = &self.ocr_mode {
c = c.ocr_mode(v.clone());
}
if let Some(s) = self.ocr_scale {
c = c.ocr_scale(s);
}
if let Some(s) = self.images_scale {
c = c.images_scale(s);
}
if let Some(v) = self.page_images {
c = c.generate_page_images(v);
}
if let Some(v) = self.do_picture_classification {
c = c.do_picture_classification(v);
}
if let Some(v) = self.do_code_enrichment {
c = c.do_code_enrichment(v);
}
if let Some(v) = self.do_formula_enrichment {
c = c.do_formula_enrichment(v);
}
Ok(c)
}
pub fn page_range(&self) -> Result<Option<(usize, usize)>, OptionsError> {
self.pages
.as_deref()
.map(|p| {
crate::parse_page_range(p)
.map_err(|e| OptionsError::new("pages", format!("pages: {e}")))
})
.transpose()
}
pub fn document_timeout(&self) -> Result<Option<std::time::Duration>, OptionsError> {
match self.document_timeout {
Some(s) if s.is_finite() && s > 0.0 => Ok(Some(std::time::Duration::from_secs_f64(s))),
Some(s) => Err(OptionsError::new(
"document_timeout",
format!("document_timeout must be a positive number of seconds, got {s}"),
)),
None => Ok(None),
}
}
pub fn pipeline(&self) -> Result<PipelineKind, OptionsError> {
match self.pipeline.as_deref().map(str::trim) {
None | Some("standard") => Ok(PipelineKind::Standard),
Some("vlm") => Ok(PipelineKind::Vlm),
Some(other) => Err(OptionsError::new(
"pipeline",
format!("unknown pipeline {other:?} (expected: standard, vlm)"),
)),
}
}
pub fn enrichments(&self) -> crate::EnrichmentOptions {
crate::EnrichmentOptions {
picture_classification: self.do_picture_classification.unwrap_or(false),
code: self.do_code_enrichment.unwrap_or(false),
formula: self.do_formula_enrichment.unwrap_or(false),
}
}
#[cfg(feature = "pdf")]
pub fn ocr_engine(&self) -> Result<Option<crate::OcrEngine>, OptionsError> {
self.ocr_engine
.as_deref()
.map(|v| {
crate::OcrEngine::parse(v).ok_or_else(|| {
OptionsError::new(
"ocr_engine",
format!("ocr_engine {v:?} is not {}", crate::OcrEngine::ACCEPTED),
)
})
})
.transpose()
}
#[cfg(feature = "pdf")]
pub fn effective_ocr_engine(&self) -> Result<crate::OcrEngine, OptionsError> {
Ok(self
.ocr_engine()?
.unwrap_or_else(crate::OcrEngine::from_env))
}
#[cfg(feature = "pdf")]
pub fn ocr_lang(&self) -> Result<Option<crate::OcrLang>, OptionsError> {
let Some(v) = self.ocr_lang.as_deref() else {
return Ok(None);
};
let engine = self.effective_ocr_engine()?;
engine.validate_lang(v).map_err(|e| {
OptionsError::new(
"ocr_lang",
if e.contains("ocr_lang") {
e
} else {
format!("ocr_lang: {e}")
},
)
})?;
Ok(match engine {
crate::OcrEngine::PpOcr => crate::OcrLang::parse(v),
crate::OcrEngine::Tesseract => None,
})
}
#[cfg(feature = "pdf")]
pub fn tesseract_lang(&self) -> Result<Option<String>, OptionsError> {
let Some(v) = self.ocr_lang.as_deref() else {
return Ok(None);
};
match self.effective_ocr_engine()? {
crate::OcrEngine::Tesseract => crate::tesseract_lang_arg(v)
.map(Some)
.map_err(|e| OptionsError::new("ocr_lang", e)),
crate::OcrEngine::PpOcr => Ok(None),
}
}
pub fn ocr_disabled(&self) -> bool {
self.no_ocr.unwrap_or(false) || self.skip_ocr.unwrap_or(false)
}
#[cfg(feature = "pdf")]
pub fn ocr_mode(&self) -> Result<Option<crate::OcrMode>, OptionsError> {
self.ocr_mode
.as_deref()
.map(|v| {
crate::OcrMode::parse(v).ok_or_else(|| {
OptionsError::new(
"ocr_mode",
format!(
"ocr_mode {v:?} is not \
default|full_page|layout_regions|pdf_aware_layout_regions"
),
)
})
})
.transpose()
}
#[cfg(feature = "pdf")]
pub fn image_output(&self) -> crate::ImageOutput {
crate::ImageOutput {
scale: self.images_scale,
page_images: self.page_images.unwrap_or(false),
}
}
#[cfg(feature = "vlm")]
pub fn vlm_options(&self) -> Result<Option<crate::vlm::VlmOptions>, OptionsError> {
if self.pipeline()? != PipelineKind::Vlm {
return Ok(None);
}
let set = |s: &Option<String>| s.clone().filter(|v| !v.trim().is_empty());
let mut v = crate::vlm::VlmOptions::resolve(set(&self.vlm_endpoint), set(&self.vlm_model))
.map_err(|e| {
let msg = match e {
crate::ConversionError::Parse(m) => m,
other => other.to_string(),
}
.replace("pass --vlm-endpoint", "set vlm_endpoint")
.replace("pass --vlm-model", "set vlm_model");
let field = if msg.contains("vlm_model") {
"vlm_model"
} else {
"vlm_endpoint"
};
OptionsError::new(field, msg)
})?;
if let Some(p) = set(&self.vlm_prompt) {
v.prompt = Some(p);
}
if let Some(k) = set(&self.vlm_api_key) {
v.api_key = Some(k);
}
match self.vlm_max_tokens {
Some(0) => {
return Err(OptionsError::new(
"vlm_max_tokens",
"vlm_max_tokens must be a positive integer, got 0",
))
}
Some(n) => v.max_tokens = n,
None => {}
}
v.page_range = self.page_range()?;
Ok(Some(v))
}
}
impl DocumentConverter {
pub fn from_options(options: &ConvertOptions) -> Result<Self, OptionsError> {
options.apply(Self::new())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn options_table_matches_the_struct() {
let fields = ConvertOptions::field_names();
for o in OPTIONS {
assert!(
fields.iter().any(|f| f == o.name),
"no field for row {}",
o.name
);
for a in o.aliases {
assert!(!fields.iter().any(|f| f == a), "alias {a} is also a field");
}
}
for f in &fields {
assert!(
OPTIONS.iter().any(|o| o.name == f),
"no OPTIONS row for field {f}"
);
}
assert_eq!(cli_flag("ocr_scale"), "--ocr-scale");
assert_eq!(cli_flag("do_code_enrichment"), "--enrich-code");
}
#[test]
fn aliases_deserialize_and_unknown_keys_are_reported() {
let o: ConvertOptions = serde_json::from_str(
r#"{"md_page_break_placeholder": "<!-- p -->", "generate_page_images": true}"#,
)
.unwrap();
assert_eq!(o.page_break_placeholder.as_deref(), Some("<!-- p -->"));
assert_eq!(o.page_images, Some(true));
assert_eq!(
ConvertOptions::unknown_keys(
["to", "strict", "md_page_break_placeholder", "strictness"],
&["to"]
),
vec!["strictness".to_string()]
);
}
#[test]
fn merge_prefers_the_overlay_and_keeps_the_rest() {
let base = ConvertOptions {
strict: Some(true),
ocr_scale: Some(2.0),
pages: Some("1-3".into()),
..Default::default()
};
let over = ConvertOptions {
strict: Some(false),
video_frames: Some(0),
..Default::default()
};
let merged = over.merge_over(base);
assert_eq!(merged.strict, Some(false));
assert_eq!(merged.ocr_scale, Some(2.0));
assert_eq!(merged.pages.as_deref(), Some("1-3"));
assert_eq!(merged.video_frames, Some(0));
}
#[test]
fn validation_rules_and_cli_spelling() {
let bad = |o: ConvertOptions| o.validate().unwrap_err();
let e = bad(ConvertOptions {
ocr_scale: Some(0.0),
..Default::default()
});
assert_eq!(e.field, "ocr_scale");
assert_eq!(e.to_string(), "ocr_scale must be a positive number, got 0");
assert_eq!(
e.cli_message(),
"--ocr-scale must be a positive number, got 0"
);
let e = bad(ConvertOptions {
images_scale: Some(9.0),
..Default::default()
});
assert_eq!(e.field, "images_scale");
let e = bad(ConvertOptions {
document_timeout: Some(-1.0),
..Default::default()
});
assert!(e.message.contains("document_timeout must be a positive"));
let e = bad(ConvertOptions {
pages: Some("5-3".into()),
..Default::default()
});
assert_eq!(e.field, "pages");
assert!(
e.cli_message().starts_with("--pages: "),
"{}",
e.cli_message()
);
let e = bad(ConvertOptions {
pipeline: Some("magic".into()),
..Default::default()
});
assert!(e.message.contains("unknown pipeline"), "{e}");
let e = bad(ConvertOptions {
vlm_max_tokens: Some(0),
..Default::default()
});
assert_eq!(
e.cli_message(),
"--vlm-max-tokens must be a positive integer, got 0"
);
assert!(ConvertOptions::default().validate().is_ok());
}
#[cfg(feature = "pdf")]
#[test]
fn ocr_options_are_validated_against_the_engine() {
let e = ConvertOptions {
ocr_engine: Some("easyocr".into()),
..Default::default()
}
.validate()
.unwrap_err();
assert_eq!(e.field, "ocr_engine");
assert!(
e.cli_message().starts_with("--ocr-engine \"easyocr\""),
"{}",
e.cli_message()
);
let e = ConvertOptions {
ocr_engine: Some("ppocr".into()),
ocr_lang: Some("deu".into()),
..Default::default()
}
.validate()
.unwrap_err();
assert_eq!(e.field, "ocr_lang");
assert!(e.message.contains("ocr_lang"), "{e}");
let tess = ConvertOptions {
ocr_engine: Some("tesseract".into()),
ocr_lang: Some("deu+fra".into()),
..Default::default()
};
assert!(tess.validate().is_ok());
assert_eq!(tess.ocr_lang().unwrap(), None);
assert_eq!(tess.tesseract_lang().unwrap().as_deref(), Some("deu+fra"));
let e = ConvertOptions {
ocr_mode: Some("sideways".into()),
..Default::default()
}
.validate()
.unwrap_err();
assert_eq!(e.field, "ocr_mode");
}
#[test]
fn apply_sets_only_what_is_given() {
let base = DocumentConverter::new().strict(true);
let mut doc = docling_core::DoclingDocument::new("t");
ConvertOptions::default()
.apply(base.clone())
.unwrap()
.finish_document(&mut doc);
assert!(doc.strict_markdown, "unset keeps the base's strict");
let mut doc = docling_core::DoclingDocument::new("t");
ConvertOptions {
strict: Some(false),
page_break_placeholder: Some("<!-- p -->".into()),
..Default::default()
}
.apply(base)
.unwrap()
.finish_document(&mut doc);
assert!(!doc.strict_markdown);
assert_eq!(doc.page_break_placeholder.as_deref(), Some("<!-- p -->"));
assert!(DocumentConverter::from_options(&ConvertOptions {
pages: Some("0-2".into()),
..Default::default()
})
.is_err());
}
#[cfg(feature = "vlm")]
#[test]
fn vlm_options_follow_the_pipeline_switch() {
let stray = ConvertOptions {
vlm_endpoint: Some("http://127.0.0.1:9/v1".into()),
..Default::default()
};
assert!(matches!(stray.vlm_options(), Ok(None)));
let full = ConvertOptions {
pipeline: Some("vlm".into()),
vlm_endpoint: Some("http://example.com/v1".into()),
vlm_model: Some("m".into()),
vlm_api_key: Some("sk-test".into()),
vlm_prompt: Some("Read the page.".into()),
vlm_max_tokens: Some(512),
pages: Some("2-5".into()),
..Default::default()
};
let v = full.vlm_options().unwrap().expect("resolves");
assert_eq!(v.endpoint, "http://example.com/v1");
assert_eq!(v.model, "m");
assert_eq!(v.api_key.as_deref(), Some("sk-test"));
assert_eq!(v.prompt.as_deref(), Some("Read the page."));
assert_eq!(v.max_tokens, 512);
assert_eq!(v.page_range, Some((2, 5)));
let e = ConvertOptions {
vlm_max_tokens: Some(0),
..full.clone()
}
.vlm_options()
.unwrap_err();
assert_eq!(e.field, "vlm_max_tokens");
if std::env::var_os("DOCLING_RS_VLM_MODEL").is_none() {
let e = ConvertOptions {
vlm_model: None,
..full
}
.vlm_options()
.unwrap_err();
assert_eq!(e.field, "vlm_model");
assert!(e.message.contains("set vlm_model"), "{e}");
assert!(
e.cli_message().contains("--vlm-model"),
"{}",
e.cli_message()
);
assert!(e.message.contains("DOCLING_RS_VLM_MODEL"), "{e}");
}
}
#[test]
fn ocr_spellings_and_pdf_password_parse() {
let both: ConvertOptions = serde_json::from_str(
r#"{"no_ocr": false, "skip_ocr": true, "text_layer_only": true,
"pdf_password": "1234"}"#,
)
.expect("both spellings parse");
assert!(both.ocr_disabled());
assert_eq!(both.text_layer_only, Some(true));
assert_eq!(both.pdf_password.as_deref(), Some("1234"));
let new: ConvertOptions = serde_json::from_str(r#"{"no_ocr": true}"#).unwrap();
assert!(new.ocr_disabled());
assert!(!ConvertOptions::default().ocr_disabled());
let merged = new.merge_over(both);
assert!(merged.ocr_disabled());
assert_eq!(merged.pdf_password.as_deref(), Some("1234"));
}
}