1use std::fmt;
40
41use serde::de::DeserializeOwned;
42use serde::{Deserialize, Serialize};
43
44use docling_core::{CustomPattern, ImageRedaction, PiiKind, RedactionOptions, Replacement};
45
46use crate::DocumentConverter;
47
48#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
51#[serde(default)]
52pub struct ConvertOptions {
53 pub strict: Option<bool>,
56 pub compact_tables: Option<bool>,
58 #[serde(alias = "md_page_break_placeholder")]
62 pub page_break_placeholder: Option<String>,
63
64 pub fetch_images: Option<bool>,
69 pub image_sources: Option<String>,
76 #[serde(default, deserialize_with = "de_string_list")]
80 pub image_hosts: Option<Vec<String>>,
81 pub max_image_bytes: Option<u64>,
84 pub max_images: Option<usize>,
87 pub max_image_total_mb: Option<u64>,
90 pub min_image_bytes: Option<u64>,
93 pub list_attachments: Option<bool>,
95 pub skip_empty_cells: Option<bool>,
97 pub ebcdic_layout: Option<String>,
99 pub encoding: Option<String>,
101 pub use_web_browser: Option<bool>,
103 pub xbrl_taxonomy: Option<String>,
105
106 pub asr_model: Option<String>,
109 pub asr_lang: Option<String>,
111 #[serde(deserialize_with = "de_video_frames")]
115 pub video_frames: Option<usize>,
116 pub video_scene_threshold: Option<f32>,
119 pub video_frame_max_side: Option<u32>,
123 pub video_frame_dedupe: Option<u32>,
127
128 pub pages: Option<String>,
131 pub document_timeout: Option<f64>,
133 pub text_layer_only: Option<bool>,
135 pub no_ocr: Option<bool>,
138 pub skip_ocr: Option<bool>,
143 #[serde(alias = "pdf_password")]
147 pub password: Option<String>,
148 pub force_full_page_ocr: Option<bool>,
150 pub no_table_former: Option<bool>,
152 pub no_text_panels: Option<bool>,
154 pub heading_hierarchy: Option<bool>,
156 pub ocr_engine: Option<String>,
158 pub ocr_lang: Option<String>,
161 pub ocr_mode: Option<String>,
164 pub ocr_scale: Option<f32>,
166 pub images_scale: Option<f32>,
168 #[serde(alias = "generate_page_images")]
171 pub page_images: Option<bool>,
172 pub do_picture_classification: Option<bool>,
174 pub do_code_enrichment: Option<bool>,
176 pub do_formula_enrichment: Option<bool>,
178 pub do_picture_ocr: Option<bool>,
181 pub picture_ocr_classes: Option<String>,
184 pub picture_ocr_min_side: Option<u32>,
187 pub keep_picture_images: Option<bool>,
190
191 pub redact_pii: Option<bool>,
194 pub redact_mode: Option<String>,
196 pub redact_kinds: Option<String>,
200 pub redact_pattern: Option<String>,
203 pub redact_images: Option<String>,
205
206 pub pipeline: Option<String>,
210 pub vlm_endpoint: Option<String>,
212 pub vlm_model: Option<String>,
214 pub vlm_api_key: Option<String>,
216 pub vlm_prompt: Option<String>,
218 pub vlm_max_tokens: Option<usize>,
220}
221
222#[derive(Debug, Clone, PartialEq, Eq)]
226pub struct OptionsError {
227 pub field: &'static str,
229 pub message: String,
231}
232
233impl OptionsError {
234 fn new(field: &'static str, message: impl Into<String>) -> Self {
235 Self {
236 field,
237 message: message.into(),
238 }
239 }
240
241 pub fn cli_message(&self) -> String {
244 let flag = cli_flag(self.field);
245 if self.message.contains(self.field) {
246 self.message.replacen(self.field, &flag, 1)
247 } else {
248 format!("{flag}: {}", self.message)
249 }
250 }
251}
252
253impl fmt::Display for OptionsError {
254 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
255 f.write_str(&self.message)
256 }
257}
258
259impl std::error::Error for OptionsError {}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263pub enum PipelineKind {
264 Standard,
266 Vlm,
268}
269
270#[derive(Debug, Clone, Copy)]
274pub struct OptionInfo {
275 pub name: &'static str,
277 pub aliases: &'static [&'static str],
279 pub cli: Option<&'static str>,
281 pub python: Option<&'static str>,
285 pub node: Option<&'static str>,
289}
290
291const fn row(name: &'static str) -> OptionInfo {
292 OptionInfo {
293 name,
294 aliases: &[],
295 cli: None,
296 python: None,
297 node: None,
298 }
299}
300
301pub const OPTIONS: &[OptionInfo] = &[
303 OptionInfo {
306 python: Some(""),
307 ..row("strict")
308 },
309 row("compact_tables"),
310 OptionInfo {
313 aliases: &["md_page_break_placeholder"],
314 python: Some(""),
315 ..row("page_break_placeholder")
316 },
317 row("fetch_images"),
318 row("image_sources"),
319 row("image_hosts"),
320 row("max_image_bytes"),
321 row("max_images"),
322 row("max_image_total_mb"),
323 row("min_image_bytes"),
324 row("list_attachments"),
325 row("skip_empty_cells"),
326 row("ebcdic_layout"),
327 row("encoding"),
328 OptionInfo {
331 node: Some(""),
332 ..row("use_web_browser")
333 },
334 row("xbrl_taxonomy"),
335 row("asr_model"),
336 row("asr_lang"),
337 row("video_frames"),
338 row("video_scene_threshold"),
339 row("video_frame_max_side"),
340 row("video_frame_dedupe"),
341 OptionInfo {
343 python: Some("page_range"),
344 ..row("pages")
345 },
346 row("document_timeout"),
347 row("text_layer_only"),
353 OptionInfo {
354 python: Some("do_ocr"),
355 ..row("no_ocr")
356 },
357 OptionInfo {
358 python: Some(""),
359 ..row("skip_ocr")
360 },
361 OptionInfo {
364 aliases: &["pdf_password"],
365 ..row("password")
366 },
367 row("force_full_page_ocr"),
368 OptionInfo {
369 python: Some("do_table_structure"),
370 ..row("no_table_former")
371 },
372 row("no_text_panels"),
373 row("heading_hierarchy"),
374 row("ocr_engine"),
375 row("ocr_lang"),
376 row("ocr_mode"),
377 row("ocr_scale"),
378 row("images_scale"),
379 OptionInfo {
380 aliases: &["generate_page_images"],
381 python: Some("generate_page_images"),
382 ..row("page_images")
383 },
384 OptionInfo {
385 cli: Some("--enrich-picture-classes"),
386 ..row("do_picture_classification")
387 },
388 OptionInfo {
389 cli: Some("--enrich-code"),
390 ..row("do_code_enrichment")
391 },
392 OptionInfo {
393 cli: Some("--enrich-formula"),
394 ..row("do_formula_enrichment")
395 },
396 OptionInfo {
397 cli: Some("--picture-ocr"),
398 ..row("do_picture_ocr")
399 },
400 row("picture_ocr_classes"),
401 row("picture_ocr_min_side"),
402 OptionInfo {
403 cli: Some("--no-picture-images"),
404 ..row("keep_picture_images")
405 },
406 row("redact_pii"),
407 row("redact_mode"),
408 row("redact_kinds"),
409 row("redact_pattern"),
410 row("redact_images"),
411 row("pipeline"),
412 row("vlm_endpoint"),
413 row("vlm_model"),
414 row("vlm_api_key"),
415 row("vlm_prompt"),
416 row("vlm_max_tokens"),
417];
418
419pub fn cli_flag(field: &str) -> String {
422 OPTIONS
423 .iter()
424 .find(|o| o.name == field)
425 .and_then(|o| o.cli)
426 .map(str::to_string)
427 .unwrap_or_else(|| format!("--{}", field.replace('_', "-")))
428}
429
430pub fn merge_options<T: Serialize + DeserializeOwned + Default>(over: T, base: T) -> T {
436 let mut merged = serde_json::to_value(base).unwrap_or(serde_json::Value::Null);
437 let over = serde_json::to_value(over).unwrap_or(serde_json::Value::Null);
438 if let (Some(into), Some(from)) = (merged.as_object_mut(), over.as_object()) {
439 for (key, value) in from {
440 if !value.is_null() {
441 into.insert(key.clone(), value.clone());
442 }
443 }
444 }
445 serde_json::from_value(merged).unwrap_or_default()
448}
449
450fn de_string_list<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<Vec<String>>, D::Error> {
453 #[derive(Deserialize)]
454 #[serde(untagged)]
455 enum Raw {
456 List(Vec<String>),
457 Text(String),
458 }
459 Ok(Option::<Raw>::deserialize(d)?.map(|raw| {
460 let items: Vec<String> = match raw {
461 Raw::List(v) => v,
462 Raw::Text(s) => s.split(',').map(str::to_string).collect(),
463 };
464 items
465 .into_iter()
466 .map(|s| s.trim().to_string())
467 .filter(|s| !s.is_empty())
468 .collect()
469 }))
470}
471
472fn de_video_frames<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<usize>, D::Error> {
477 #[derive(Deserialize)]
478 #[serde(untagged)]
479 enum Raw {
480 Count(u64),
481 Text(String),
482 }
483 let raw = Option::<Raw>::deserialize(d)?;
484 let n = match raw {
485 None => return Ok(None),
486 Some(Raw::Count(n)) => n,
487 Some(Raw::Text(s)) => {
488 let s = s.trim();
489 if s.eq_ignore_ascii_case("all") {
490 return Ok(Some(crate::ALL_VIDEO_FRAMES));
491 }
492 s.parse::<u64>().map_err(|_| {
493 serde::de::Error::custom(format!(
494 "video_frames: expected a non-negative integer or \"all\", got {s:?}"
495 ))
496 })?
497 }
498 };
499 Ok(Some(if n >= u32::MAX as u64 {
500 crate::ALL_VIDEO_FRAMES
501 } else {
502 n as usize
503 }))
504}
505
506impl ConvertOptions {
507 pub fn image_sources(&self) -> Result<Option<crate::ImageSources>, OptionsError> {
510 self.image_sources
511 .as_deref()
512 .filter(|s| !s.trim().is_empty())
513 .map(|s| {
514 s.parse::<crate::ImageSources>()
515 .map_err(|e| OptionsError::new("image_sources", e))
516 })
517 .transpose()
518 }
519
520 pub fn field_names() -> Vec<String> {
525 match serde_json::to_value(Self::default()) {
526 Ok(serde_json::Value::Object(map)) => map.keys().cloned().collect(),
527 _ => Vec::new(),
528 }
529 }
530
531 pub fn unknown_keys<'a>(
535 keys: impl IntoIterator<Item = &'a str>,
536 also_known: &[&str],
537 ) -> Vec<String> {
538 let fields = Self::field_names();
539 keys.into_iter()
540 .filter(|k| {
541 !fields.iter().any(|f| f == k)
542 && !also_known.contains(k)
543 && !OPTIONS.iter().any(|o| o.aliases.contains(k))
544 })
545 .map(str::to_string)
546 .collect()
547 }
548
549 pub fn merge_over(self, base: ConvertOptions) -> ConvertOptions {
551 merge_options(self, base)
552 }
553
554 pub fn validate(&self) -> Result<(), OptionsError> {
564 self.page_range()?;
565 self.document_timeout()?;
566 if let Some(s) = self.ocr_scale {
567 if !(s.is_finite() && s > 0.0) {
568 return Err(OptionsError::new(
569 "ocr_scale",
570 format!("ocr_scale must be a positive number, got {s}"),
571 ));
572 }
573 }
574 if let Some(s) = self.images_scale {
575 if !(0.1..=4.0).contains(&s) {
576 return Err(OptionsError::new(
577 "images_scale",
578 format!("images_scale must be a number in 0.1-4.0, got {s}"),
579 ));
580 }
581 }
582 self.image_sources()?;
583 if self.max_image_bytes == Some(0) {
584 return Err(OptionsError::new(
585 "max_image_bytes",
586 "max_image_bytes must be a positive number of bytes, got 0",
587 ));
588 }
589 if let Some(t) = self.video_scene_threshold {
590 if !(t.is_finite() && (0.0..=1.0).contains(&t)) {
591 return Err(OptionsError::new(
592 "video_scene_threshold",
593 format!("video_scene_threshold must be a number in 0.0-1.0, got {t}"),
594 ));
595 }
596 }
597 if let Some(d) = self.video_frame_dedupe {
598 if d > 64 {
599 return Err(OptionsError::new(
600 "video_frame_dedupe",
601 format!("video_frame_dedupe must be a Hamming distance in 0-64, got {d}"),
602 ));
603 }
604 }
605 self.pipeline()?;
606 if self.vlm_max_tokens == Some(0) {
607 return Err(OptionsError::new(
608 "vlm_max_tokens",
609 "vlm_max_tokens must be a positive integer, got 0",
610 ));
611 }
612 #[cfg(feature = "pdf")]
613 {
614 self.ocr_engine()?;
615 self.ocr_mode()?;
616 self.ocr_lang()?;
617 }
618 self.picture_ocr_classes()?;
619 self.redaction()?;
620 Ok(())
621 }
622
623 pub fn picture_ocr_classes(&self) -> Result<Vec<String>, OptionsError> {
629 let labels: Vec<String> = self
630 .picture_ocr_classes
631 .as_deref()
632 .unwrap_or("")
633 .split(',')
634 .map(|s| s.trim().to_ascii_lowercase())
635 .filter(|s| !s.is_empty())
636 .collect();
637 #[cfg(feature = "pdf")]
638 if let Some(bad) = labels
639 .iter()
640 .find(|l| !docling_pdf::enrich::PICTURE_CLASSES.contains(&l.as_str()))
641 {
642 return Err(OptionsError::new(
643 "picture_ocr_classes",
644 format!(
645 "picture_ocr_classes: {bad:?} is not a DocumentFigureClassifier label ({})",
646 docling_pdf::enrich::PICTURE_CLASSES.join(", ")
647 ),
648 ));
649 }
650 Ok(labels)
651 }
652
653 pub fn redaction(&self) -> Result<Option<RedactionOptions>, OptionsError> {
658 let mut opts = RedactionOptions::default();
659 if let Some(m) = self.redact_mode.as_deref().filter(|m| !m.trim().is_empty()) {
660 opts.replacement = Replacement::parse(m).ok_or_else(|| {
661 OptionsError::new(
662 "redact_mode",
663 format!(
664 "redact_mode must be one of {} , got {m:?}",
665 Replacement::ACCEPTED
666 ),
667 )
668 })?;
669 }
670 if let Some(k) = self.redact_kinds.as_deref() {
671 for name in k.split(',').map(str::trim).filter(|s| !s.is_empty()) {
672 let kind = PiiKind::parse(name).ok_or_else(|| {
673 OptionsError::new(
674 "redact_kinds",
675 format!(
676 "redact_kinds: {name:?} is not a PII kind ({})",
677 PiiKind::ACCEPTED
678 ),
679 )
680 })?;
681 if !opts.kinds.contains(&kind) {
682 opts.kinds.push(kind);
683 }
684 }
685 }
686 if let Some(p) = self.redact_pattern.as_deref() {
687 opts.custom_patterns = p.lines().filter_map(CustomPattern::parse).collect();
688 docling_core::redact::PatternDetector::new(&opts)
690 .map_err(|e| OptionsError::new("redact_pattern", e.0))?;
691 }
692 if let Some(i) = self
693 .redact_images
694 .as_deref()
695 .filter(|i| !i.trim().is_empty())
696 {
697 opts.images = ImageRedaction::parse(i).ok_or_else(|| {
698 OptionsError::new(
699 "redact_images",
700 format!(
701 "redact_images must be one of {}, got {i:?}",
702 ImageRedaction::ACCEPTED
703 ),
704 )
705 })?;
706 }
707 Ok(self.redact_pii.unwrap_or(false).then_some(opts))
708 }
709
710 pub fn apply(&self, mut c: DocumentConverter) -> Result<DocumentConverter, OptionsError> {
715 self.validate()?;
716 if let Some(v) = self.strict {
717 c = c.strict(v);
718 }
719 if let Some(v) = self.compact_tables {
720 c = c.compact_tables(v);
721 }
722 if self.page_break_placeholder.is_some() {
723 c = c.page_break_placeholder(self.page_break_placeholder.clone());
724 }
725 if let Some(v) = self.fetch_images {
726 c = c.fetch_images(v);
727 }
728 if let Some(sources) = self.image_sources()? {
729 c = c.image_sources(sources);
730 }
731 if let Some(hosts) = &self.image_hosts {
732 c = c.image_hosts(hosts.iter().cloned());
733 }
734 if let Some(v) = self.max_image_bytes {
735 c = c.max_image_bytes(v);
736 }
737 if let Some(v) = self.max_images {
738 c = c.max_images(v);
739 }
740 if let Some(v) = self.max_image_total_mb {
741 c = c.max_image_total_mb(v);
742 }
743 if let Some(v) = self.min_image_bytes {
744 c = c.min_image_bytes(v);
745 }
746 if let Some(v) = self.list_attachments {
747 c = c.list_attachments(v);
748 }
749 if let Some(v) = self.skip_empty_cells {
750 c = c.skip_empty_cells(v);
751 }
752 if self.ebcdic_layout.is_some() {
753 c = c.ebcdic_layout_opt(self.ebcdic_layout.clone());
754 }
755 if self.encoding.is_some() {
756 c = c.encoding(self.encoding.clone());
757 }
758 if let Some(v) = self.use_web_browser {
759 c = c.use_web_browser(v);
760 }
761 if let Some(dir) = &self.xbrl_taxonomy {
762 c = c.xbrl_taxonomy(dir);
763 }
764 if self.asr_model.is_some() {
765 c = c.asr_model(self.asr_model.clone());
766 }
767 if self.asr_lang.is_some() {
768 c = c.asr_lang(self.asr_lang.clone());
769 }
770 if let Some(n) = self.video_frames {
771 c = c.video_frames(n);
772 }
773 if let Some(t) = self.video_scene_threshold {
774 c = c.video_scene_threshold(t);
775 }
776 if let Some(px) = self.video_frame_max_side {
777 c = c.video_frame_max_side(px);
778 }
779 if let Some(d) = self.video_frame_dedupe {
780 c = c.video_frame_dedupe(Some(d));
781 }
782 if let Some((first, last)) = self.page_range()? {
783 c = c.page_range(first, last);
784 }
785 if self.document_timeout.is_some() {
786 c = c.document_timeout(self.document_timeout()?);
787 }
788 if let Some(v) = self.text_layer_only {
789 c = c.text_layer_only(v);
790 }
791 if self.no_ocr.is_some() || self.skip_ocr.is_some() {
792 c = c.no_ocr(self.ocr_disabled());
793 }
794 if self.password.is_some() {
795 c = c.password(self.password.clone());
796 }
797 if let Some(v) = self.force_full_page_ocr {
798 c = c.force_full_page_ocr(v);
799 }
800 if let Some(v) = self.no_table_former {
801 c = c.no_table_former(v);
802 }
803 if let Some(v) = self.no_text_panels {
804 c = c.no_text_panels(v);
805 }
806 if let Some(v) = self.heading_hierarchy {
807 c = c.heading_hierarchy(v);
808 }
809 if let Some(v) = &self.ocr_engine {
810 c = c.ocr_engine(v.clone());
811 }
812 if let Some(v) = &self.ocr_lang {
813 c = c.ocr_lang(v.clone());
814 }
815 if let Some(v) = &self.ocr_mode {
816 c = c.ocr_mode(v.clone());
817 }
818 if let Some(s) = self.ocr_scale {
819 c = c.ocr_scale(s);
820 }
821 if let Some(s) = self.images_scale {
822 c = c.images_scale(s);
823 }
824 if let Some(v) = self.page_images {
825 c = c.generate_page_images(v);
826 }
827 if let Some(v) = self.do_picture_classification {
828 c = c.do_picture_classification(v);
829 }
830 if let Some(v) = self.do_code_enrichment {
831 c = c.do_code_enrichment(v);
832 }
833 if let Some(v) = self.do_formula_enrichment {
834 c = c.do_formula_enrichment(v);
835 }
836 if let Some(v) = self.do_picture_ocr {
837 c = c.do_picture_ocr(v);
838 }
839 if self.picture_ocr_classes.is_some() {
840 c = c.picture_ocr_classes(self.picture_ocr_classes()?);
841 }
842 if let Some(v) = self.picture_ocr_min_side {
843 c = c.picture_ocr_min_side(v);
844 }
845 if let Some(v) = self.keep_picture_images {
846 c = c.keep_picture_images(v);
847 }
848 if self.redact_pii.is_some() {
849 c = c.redact_pii_opt(self.redaction()?);
850 }
851 Ok(c)
852 }
853
854 pub fn page_range(&self) -> Result<Option<(usize, usize)>, OptionsError> {
859 self.pages
860 .as_deref()
861 .map(|p| {
862 crate::parse_page_range(p)
863 .map_err(|e| OptionsError::new("pages", format!("pages: {e}")))
864 })
865 .transpose()
866 }
867
868 pub fn document_timeout(&self) -> Result<Option<std::time::Duration>, OptionsError> {
870 match self.document_timeout {
871 Some(s) if s.is_finite() && s > 0.0 => Ok(Some(std::time::Duration::from_secs_f64(s))),
872 Some(s) => Err(OptionsError::new(
873 "document_timeout",
874 format!("document_timeout must be a positive number of seconds, got {s}"),
875 )),
876 None => Ok(None),
877 }
878 }
879
880 pub fn pipeline(&self) -> Result<PipelineKind, OptionsError> {
882 match self.pipeline.as_deref().map(str::trim) {
883 None | Some("standard") => Ok(PipelineKind::Standard),
884 Some("vlm") => Ok(PipelineKind::Vlm),
885 Some(other) => Err(OptionsError::new(
886 "pipeline",
887 format!("unknown pipeline {other:?} (expected: standard, vlm)"),
888 )),
889 }
890 }
891
892 pub fn enrichments(&self) -> crate::EnrichmentOptions {
894 crate::EnrichmentOptions {
895 picture_classification: self.do_picture_classification.unwrap_or(false),
896 code: self.do_code_enrichment.unwrap_or(false),
897 formula: self.do_formula_enrichment.unwrap_or(false),
898 }
899 }
900
901 #[cfg(feature = "pdf")]
903 pub fn ocr_engine(&self) -> Result<Option<crate::OcrEngine>, OptionsError> {
904 self.ocr_engine
905 .as_deref()
906 .map(|v| {
907 crate::OcrEngine::parse(v).ok_or_else(|| {
908 OptionsError::new(
909 "ocr_engine",
910 format!("ocr_engine {v:?} is not {}", crate::OcrEngine::ACCEPTED),
911 )
912 })
913 })
914 .transpose()
915 }
916
917 #[cfg(feature = "pdf")]
920 pub fn effective_ocr_engine(&self) -> Result<crate::OcrEngine, OptionsError> {
921 Ok(self
922 .ocr_engine()?
923 .unwrap_or_else(crate::OcrEngine::from_env))
924 }
925
926 #[cfg(feature = "pdf")]
930 pub fn ocr_lang(&self) -> Result<Option<crate::OcrLang>, OptionsError> {
931 let Some(v) = self.ocr_lang.as_deref() else {
932 return Ok(None);
933 };
934 let engine = self.effective_ocr_engine()?;
935 engine.validate_lang(v).map_err(|e| {
936 OptionsError::new(
937 "ocr_lang",
938 if e.contains("ocr_lang") {
939 e
940 } else {
941 format!("ocr_lang: {e}")
942 },
943 )
944 })?;
945 Ok(match engine {
946 crate::OcrEngine::PpOcr => crate::OcrLang::parse(v),
947 crate::OcrEngine::Tesseract => None,
948 })
949 }
950
951 #[cfg(feature = "pdf")]
954 pub fn tesseract_lang(&self) -> Result<Option<String>, OptionsError> {
955 let Some(v) = self.ocr_lang.as_deref() else {
956 return Ok(None);
957 };
958 match self.effective_ocr_engine()? {
959 crate::OcrEngine::Tesseract => crate::tesseract_lang_arg(v)
960 .map(Some)
961 .map_err(|e| OptionsError::new("ocr_lang", e)),
962 crate::OcrEngine::PpOcr => Ok(None),
963 }
964 }
965
966 pub fn ocr_disabled(&self) -> bool {
969 self.no_ocr.unwrap_or(false) || self.skip_ocr.unwrap_or(false)
970 }
971
972 #[cfg(feature = "pdf")]
974 pub fn ocr_mode(&self) -> Result<Option<crate::OcrMode>, OptionsError> {
975 self.ocr_mode
976 .as_deref()
977 .map(|v| {
978 crate::OcrMode::parse(v).ok_or_else(|| {
979 OptionsError::new(
980 "ocr_mode",
981 format!(
982 "ocr_mode {v:?} is not \
983 default|full_page|layout_regions|pdf_aware_layout_regions"
984 ),
985 )
986 })
987 })
988 .transpose()
989 }
990
991 #[cfg(feature = "pdf")]
993 pub fn image_output(&self) -> crate::ImageOutput {
994 crate::ImageOutput {
995 scale: self.images_scale,
996 page_images: self.page_images.unwrap_or(false),
997 }
998 }
999
1000 #[cfg(feature = "vlm")]
1008 pub fn vlm_options(&self) -> Result<Option<crate::vlm::VlmOptions>, OptionsError> {
1009 if self.pipeline()? != PipelineKind::Vlm {
1010 return Ok(None);
1011 }
1012 let set = |s: &Option<String>| s.clone().filter(|v| !v.trim().is_empty());
1013 let mut v = crate::vlm::VlmOptions::resolve(set(&self.vlm_endpoint), set(&self.vlm_model))
1014 .map_err(|e| {
1015 let msg = match e {
1018 crate::ConversionError::Parse(m) => m,
1019 other => other.to_string(),
1020 }
1021 .replace("pass --vlm-endpoint", "set vlm_endpoint")
1022 .replace("pass --vlm-model", "set vlm_model");
1023 let field = if msg.contains("vlm_model") {
1024 "vlm_model"
1025 } else {
1026 "vlm_endpoint"
1027 };
1028 OptionsError::new(field, msg)
1029 })?;
1030 if let Some(p) = set(&self.vlm_prompt) {
1031 v.prompt = Some(p);
1032 }
1033 if let Some(k) = set(&self.vlm_api_key) {
1034 v.api_key = Some(k);
1035 }
1036 match self.vlm_max_tokens {
1037 Some(0) => {
1040 return Err(OptionsError::new(
1041 "vlm_max_tokens",
1042 "vlm_max_tokens must be a positive integer, got 0",
1043 ))
1044 }
1045 Some(n) => v.max_tokens = n,
1046 None => {}
1047 }
1048 v.page_range = self.page_range()?;
1049 Ok(Some(v))
1050 }
1051}
1052
1053impl DocumentConverter {
1054 pub fn from_options(options: &ConvertOptions) -> Result<Self, OptionsError> {
1057 options.apply(Self::new())
1058 }
1059}
1060
1061#[cfg(test)]
1062mod tests {
1063 use super::*;
1064
1065 #[test]
1068 fn options_table_matches_the_struct() {
1069 let fields = ConvertOptions::field_names();
1070 for o in OPTIONS {
1071 assert!(
1072 fields.iter().any(|f| f == o.name),
1073 "no field for row {}",
1074 o.name
1075 );
1076 for a in o.aliases {
1077 assert!(!fields.iter().any(|f| f == a), "alias {a} is also a field");
1078 }
1079 }
1080 for f in &fields {
1081 assert!(
1082 OPTIONS.iter().any(|o| o.name == f),
1083 "no OPTIONS row for field {f}"
1084 );
1085 }
1086 assert_eq!(cli_flag("ocr_scale"), "--ocr-scale");
1087 assert_eq!(cli_flag("do_code_enrichment"), "--enrich-code");
1088 }
1089
1090 #[test]
1091 fn aliases_deserialize_and_unknown_keys_are_reported() {
1092 let o: ConvertOptions = serde_json::from_str(
1093 r#"{"md_page_break_placeholder": "<!-- p -->", "generate_page_images": true}"#,
1094 )
1095 .unwrap();
1096 assert_eq!(o.page_break_placeholder.as_deref(), Some("<!-- p -->"));
1097 assert_eq!(o.page_images, Some(true));
1098 assert_eq!(
1099 ConvertOptions::unknown_keys(
1100 ["to", "strict", "md_page_break_placeholder", "strictness"],
1101 &["to"]
1102 ),
1103 vec!["strictness".to_string()]
1104 );
1105 }
1106
1107 #[test]
1111 fn video_frames_spellings_and_video_ranges() {
1112 let parse = |json: &str| serde_json::from_str::<ConvertOptions>(json).unwrap();
1113 assert_eq!(parse(r#"{"video_frames": 3}"#).video_frames, Some(3));
1114 assert_eq!(parse(r#"{"video_frames": "12"}"#).video_frames, Some(12));
1115 assert_eq!(
1116 parse(r#"{"video_frames": "all"}"#).video_frames,
1117 Some(crate::ALL_VIDEO_FRAMES)
1118 );
1119 assert_eq!(
1120 parse(r#"{"video_frames": 4294967295}"#).video_frames,
1121 Some(crate::ALL_VIDEO_FRAMES)
1122 );
1123 assert_eq!(parse("{}").video_frames, None);
1124 assert!(serde_json::from_str::<ConvertOptions>(r#"{"video_frames": "some"}"#).is_err());
1125 let merged = ConvertOptions {
1127 video_frames: Some(crate::ALL_VIDEO_FRAMES),
1128 ..Default::default()
1129 }
1130 .merge_over(ConvertOptions::default());
1131 assert_eq!(merged.video_frames, Some(crate::ALL_VIDEO_FRAMES));
1132
1133 let ok = ConvertOptions {
1134 video_scene_threshold: Some(0.6),
1135 video_frame_max_side: Some(640),
1136 video_frame_dedupe: Some(5),
1137 ..Default::default()
1138 };
1139 ok.validate().unwrap();
1140 let bad = ConvertOptions {
1141 video_scene_threshold: Some(1.5),
1142 ..Default::default()
1143 };
1144 assert_eq!(bad.validate().unwrap_err().field, "video_scene_threshold");
1145 let bad = ConvertOptions {
1146 video_frame_dedupe: Some(65),
1147 ..Default::default()
1148 };
1149 assert_eq!(bad.validate().unwrap_err().field, "video_frame_dedupe");
1150 }
1151
1152 #[test]
1153 fn merge_prefers_the_overlay_and_keeps_the_rest() {
1154 let base = ConvertOptions {
1155 strict: Some(true),
1156 ocr_scale: Some(2.0),
1157 pages: Some("1-3".into()),
1158 ..Default::default()
1159 };
1160 let over = ConvertOptions {
1161 strict: Some(false),
1162 video_frames: Some(0),
1163 ..Default::default()
1164 };
1165 let merged = over.merge_over(base);
1166 assert_eq!(merged.strict, Some(false));
1167 assert_eq!(merged.ocr_scale, Some(2.0));
1168 assert_eq!(merged.pages.as_deref(), Some("1-3"));
1169 assert_eq!(merged.video_frames, Some(0));
1170 }
1171
1172 #[test]
1173 fn validation_rules_and_cli_spelling() {
1174 let bad = |o: ConvertOptions| o.validate().unwrap_err();
1175 let e = bad(ConvertOptions {
1176 ocr_scale: Some(0.0),
1177 ..Default::default()
1178 });
1179 assert_eq!(e.field, "ocr_scale");
1180 assert_eq!(e.to_string(), "ocr_scale must be a positive number, got 0");
1181 assert_eq!(
1182 e.cli_message(),
1183 "--ocr-scale must be a positive number, got 0"
1184 );
1185
1186 let e = bad(ConvertOptions {
1187 images_scale: Some(9.0),
1188 ..Default::default()
1189 });
1190 assert_eq!(e.field, "images_scale");
1191 let e = bad(ConvertOptions {
1192 document_timeout: Some(-1.0),
1193 ..Default::default()
1194 });
1195 assert!(e.message.contains("document_timeout must be a positive"));
1196 let e = bad(ConvertOptions {
1197 pages: Some("5-3".into()),
1198 ..Default::default()
1199 });
1200 assert_eq!(e.field, "pages");
1201 assert!(
1202 e.cli_message().starts_with("--pages: "),
1203 "{}",
1204 e.cli_message()
1205 );
1206 let e = bad(ConvertOptions {
1207 pipeline: Some("magic".into()),
1208 ..Default::default()
1209 });
1210 assert!(e.message.contains("unknown pipeline"), "{e}");
1211 let e = bad(ConvertOptions {
1212 vlm_max_tokens: Some(0),
1213 ..Default::default()
1214 });
1215 assert_eq!(
1216 e.cli_message(),
1217 "--vlm-max-tokens must be a positive integer, got 0"
1218 );
1219 assert!(ConvertOptions::default().validate().is_ok());
1220 }
1221
1222 #[cfg(feature = "pdf")]
1223 #[test]
1224 fn ocr_options_are_validated_against_the_engine() {
1225 let e = ConvertOptions {
1226 ocr_engine: Some("easyocr".into()),
1227 ..Default::default()
1228 }
1229 .validate()
1230 .unwrap_err();
1231 assert_eq!(e.field, "ocr_engine");
1232 assert!(
1233 e.cli_message().starts_with("--ocr-engine \"easyocr\""),
1234 "{}",
1235 e.cli_message()
1236 );
1237 let e = ConvertOptions {
1239 ocr_engine: Some("ppocr".into()),
1240 ocr_lang: Some("deu".into()),
1241 ..Default::default()
1242 }
1243 .validate()
1244 .unwrap_err();
1245 assert_eq!(e.field, "ocr_lang");
1246 assert!(e.message.contains("ocr_lang"), "{e}");
1247 let tess = ConvertOptions {
1248 ocr_engine: Some("tesseract".into()),
1249 ocr_lang: Some("deu+fra".into()),
1250 ..Default::default()
1251 };
1252 assert!(tess.validate().is_ok());
1253 assert_eq!(tess.ocr_lang().unwrap(), None);
1254 assert_eq!(tess.tesseract_lang().unwrap().as_deref(), Some("deu+fra"));
1255 let e = ConvertOptions {
1256 ocr_mode: Some("sideways".into()),
1257 ..Default::default()
1258 }
1259 .validate()
1260 .unwrap_err();
1261 assert_eq!(e.field, "ocr_mode");
1262 }
1263
1264 #[test]
1265 fn apply_sets_only_what_is_given() {
1266 let base = DocumentConverter::new().strict(true);
1269 let mut doc = docling_core::DoclingDocument::new("t");
1270 ConvertOptions::default()
1271 .apply(base.clone())
1272 .unwrap()
1273 .finish_document(&mut doc);
1274 assert!(doc.strict_markdown, "unset keeps the base's strict");
1275 let mut doc = docling_core::DoclingDocument::new("t");
1276 ConvertOptions {
1277 strict: Some(false),
1278 page_break_placeholder: Some("<!-- p -->".into()),
1279 ..Default::default()
1280 }
1281 .apply(base)
1282 .unwrap()
1283 .finish_document(&mut doc);
1284 assert!(!doc.strict_markdown);
1285 assert_eq!(doc.page_break_placeholder.as_deref(), Some("<!-- p -->"));
1286 assert!(DocumentConverter::from_options(&ConvertOptions {
1288 pages: Some("0-2".into()),
1289 ..Default::default()
1290 })
1291 .is_err());
1292 }
1293
1294 #[cfg(feature = "vlm")]
1295 #[test]
1296 fn vlm_options_follow_the_pipeline_switch() {
1297 let stray = ConvertOptions {
1299 vlm_endpoint: Some("http://127.0.0.1:9/v1".into()),
1300 ..Default::default()
1301 };
1302 assert!(matches!(stray.vlm_options(), Ok(None)));
1303 let full = ConvertOptions {
1304 pipeline: Some("vlm".into()),
1305 vlm_endpoint: Some("http://example.com/v1".into()),
1306 vlm_model: Some("m".into()),
1307 vlm_api_key: Some("sk-test".into()),
1308 vlm_prompt: Some("Read the page.".into()),
1309 vlm_max_tokens: Some(512),
1310 pages: Some("2-5".into()),
1311 ..Default::default()
1312 };
1313 let v = full.vlm_options().unwrap().expect("resolves");
1314 assert_eq!(v.endpoint, "http://example.com/v1");
1315 assert_eq!(v.model, "m");
1316 assert_eq!(v.api_key.as_deref(), Some("sk-test"));
1317 assert_eq!(v.prompt.as_deref(), Some("Read the page."));
1318 assert_eq!(v.max_tokens, 512);
1319 assert_eq!(v.page_range, Some((2, 5)));
1320 let e = ConvertOptions {
1321 vlm_max_tokens: Some(0),
1322 ..full.clone()
1323 }
1324 .vlm_options()
1325 .unwrap_err();
1326 assert_eq!(e.field, "vlm_max_tokens");
1327 if std::env::var_os("DOCLING_RS_VLM_MODEL").is_none() {
1330 let e = ConvertOptions {
1331 vlm_model: None,
1332 ..full
1333 }
1334 .vlm_options()
1335 .unwrap_err();
1336 assert_eq!(e.field, "vlm_model");
1337 assert!(e.message.contains("set vlm_model"), "{e}");
1338 assert!(
1339 e.cli_message().contains("--vlm-model"),
1340 "{}",
1341 e.cli_message()
1342 );
1343 assert!(e.message.contains("DOCLING_RS_VLM_MODEL"), "{e}");
1344 }
1345 }
1346
1347 #[test]
1353 fn ocr_spellings_and_password_parse() {
1354 let both: ConvertOptions = serde_json::from_str(
1355 r#"{"no_ocr": false, "skip_ocr": true, "text_layer_only": true,
1356 "pdf_password": "1234"}"#,
1357 )
1358 .expect("both spellings parse");
1359 assert!(both.ocr_disabled());
1360 assert_eq!(both.text_layer_only, Some(true));
1361 assert_eq!(both.password.as_deref(), Some("1234"));
1362 let neutral: ConvertOptions = serde_json::from_str(r#"{"password": "x"}"#).unwrap();
1363 assert_eq!(neutral.password.as_deref(), Some("x"));
1364 let new: ConvertOptions = serde_json::from_str(r#"{"no_ocr": true}"#).unwrap();
1365 assert!(new.ocr_disabled());
1366 assert!(!ConvertOptions::default().ocr_disabled());
1367 let merged = new.merge_over(both);
1369 assert!(merged.ocr_disabled());
1370 assert_eq!(merged.password.as_deref(), Some("1234"));
1371 }
1372}