1use 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#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
53#[serde(default)]
54pub struct ConvertOptions {
55 pub strict: Option<bool>,
58 pub compact_tables: Option<bool>,
60 pub picture_alt_text: Option<bool>,
63 #[serde(alias = "md_page_break_placeholder")]
67 pub page_break_placeholder: Option<String>,
68
69 pub fetch_images: Option<bool>,
74 pub image_sources: Option<String>,
81 #[serde(default, deserialize_with = "de_string_list")]
85 pub image_hosts: Option<Vec<String>>,
86 pub max_image_bytes: Option<u64>,
89 pub max_images: Option<usize>,
92 pub max_image_total_mb: Option<u64>,
95 pub min_image_bytes: Option<u64>,
98 pub list_attachments: Option<bool>,
100 pub skip_empty_cells: Option<bool>,
102 pub ebcdic_layout: Option<String>,
104 pub encoding: Option<String>,
106 pub ipynb_outputs: Option<String>,
110 pub metadata: Option<String>,
116 pub use_web_browser: Option<bool>,
118 pub xbrl_taxonomy: Option<String>,
120
121 pub asr_model: Option<String>,
124 pub asr_lang: Option<String>,
126 #[serde(deserialize_with = "de_video_frames")]
130 pub video_frames: Option<usize>,
131 pub video_scene_threshold: Option<f32>,
134 pub video_frame_max_side: Option<u32>,
138 pub video_frame_dedupe: Option<u32>,
142
143 pub pages: Option<String>,
146 pub document_timeout: Option<f64>,
148 pub text_layer_only: Option<bool>,
150 pub no_ocr: Option<bool>,
153 pub skip_ocr: Option<bool>,
158 #[serde(alias = "pdf_password")]
162 pub password: Option<String>,
163 pub force_full_page_ocr: Option<bool>,
165 pub no_table_former: Option<bool>,
167 pub no_text_panels: Option<bool>,
169 pub heading_hierarchy: Option<bool>,
171 pub ocr_engine: Option<String>,
173 pub ocr_lang: Option<String>,
176 pub ocr_mode: Option<String>,
179 pub ocr_scale: Option<f32>,
181 pub ocr_textline_orientation: Option<bool>,
185 pub images_scale: Option<f32>,
187 #[serde(alias = "generate_page_images")]
190 pub page_images: Option<bool>,
191 pub do_picture_classification: Option<bool>,
193 pub do_code_enrichment: Option<bool>,
195 pub do_formula_enrichment: Option<bool>,
197 pub do_picture_ocr: Option<bool>,
200 pub picture_ocr_classes: Option<String>,
203 pub picture_ocr_min_side: Option<u32>,
206 pub picture_ocr_scale: Option<f32>,
210 pub keep_picture_images: Option<bool>,
213 pub do_picture_description: Option<bool>,
217 pub picture_description_endpoint: Option<String>,
220 pub picture_description_model: Option<String>,
222 pub picture_description_api_key: Option<String>,
224 pub picture_description_prompt: Option<String>,
227 pub picture_description_mode: Option<String>,
229 pub picture_description_concurrency: Option<usize>,
231
232 pub redact_pii: Option<bool>,
235 pub redact_mode: Option<String>,
237 pub redact_kinds: Option<String>,
241 pub redact_pattern: Option<String>,
244 pub redact_images: Option<String>,
246 pub redact_allow_terms: Option<String>,
249 pub redact_deny_terms: Option<String>,
252 pub redact_allow_patterns: Option<String>,
255 pub redact_ner_min_score: Option<f32>,
257
258 pub pipeline: Option<String>,
262 pub vlm_endpoint: Option<String>,
264 pub vlm_model: Option<String>,
266 pub vlm_api_key: Option<String>,
268 pub vlm_prompt: Option<String>,
270 pub vlm_max_tokens: Option<usize>,
272}
273
274#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
276pub enum PictureDescriptionMode {
277 #[default]
279 Caption,
280 Ocr,
284}
285
286impl PictureDescriptionMode {
287 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 pub fn default_prompt(self) -> &'static str {
305 match self {
306 Self::Caption => CAPTION_PROMPT,
307 Self::Ocr => OCR_PROMPT,
308 }
309 }
310}
311
312pub const CAPTION_PROMPT: &str = "Write a detailed caption for this image.";
314
315pub 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#[derive(Debug, Clone, PartialEq, Eq)]
324pub struct OptionsError {
325 pub field: &'static str,
327 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
361pub enum PipelineKind {
362 Standard,
364 Vlm,
366}
367
368#[derive(Debug, Clone, Copy)]
372pub struct OptionInfo {
373 pub name: &'static str,
375 pub aliases: &'static [&'static str],
377 pub cli: Option<&'static str>,
379 pub python: Option<&'static str>,
383 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
399pub const OPTIONS: &[OptionInfo] = &[
401 OptionInfo {
404 python: Some(""),
405 ..row("strict")
406 },
407 row("compact_tables"),
408 row("picture_alt_text"),
409 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 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 OptionInfo {
444 python: Some("page_range"),
445 ..row("pages")
446 },
447 row("document_timeout"),
448 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 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
539pub 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
550pub 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 serde_json::from_value(merged).unwrap_or_default()
568}
569
570fn 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
592fn 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 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 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 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 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 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 pub fn merge_over(self, base: ConvertOptions) -> ConvertOptions {
705 merge_options(self, base)
706 }
707
708 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 pub fn ocr_disabled(&self) -> bool {
1358 self.no_ocr.unwrap_or(false) || self.skip_ocr.unwrap_or(false)
1359 }
1360
1361 #[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 #[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 #[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 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 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 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 #[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 #[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 #[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 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 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 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 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 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 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 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 #[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 let merged = new.merge_over(both);
1824 assert!(merged.ocr_disabled());
1825 assert_eq!(merged.password.as_deref(), Some("1234"));
1826 }
1827}