1pub const MAX_SOURCE_BYTES: u64 = 128 * 1024 * 1024;
25
26pub const MAX_PART_BYTES: u64 = 64 * 1024 * 1024;
29
30pub const MAX_OUTPUT_BYTES: usize = 64 * 1024 * 1024;
32
33pub const MAX_SHEET_ROWS: u32 = 1_048_576;
35
36pub const MAX_SHEET_COLUMNS: u32 = 16_384;
38
39pub const MAX_SHEET_CELLS: u64 = 10_000_000;
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
49#[serde(deny_unknown_fields)]
50pub struct RecordsLimits {
51 pub max_records: u64,
53 pub max_tables: u64,
55 pub max_output_bytes: u64,
57}
58
59impl Default for RecordsLimits {
60 fn default() -> RecordsLimits {
61 RecordsLimits {
62 max_records: 100_000,
63 max_tables: 256,
64 max_output_bytes: 64 * 1024 * 1024,
65 }
66 }
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
77#[serde(deny_unknown_fields)]
78pub struct ImageOcrLimits {
79 pub wall_timeout_secs: u64,
81 pub max_response_bytes: u32,
83 pub max_stderr_bytes: u64,
85 pub address_space_bytes: u64,
87 pub cpu_seconds: u64,
89 pub file_size_bytes: u64,
91 pub max_processes: u64,
93}
94
95impl Default for ImageOcrLimits {
96 fn default() -> ImageOcrLimits {
97 ImageOcrLimits {
98 wall_timeout_secs: 300,
99 max_response_bytes: 8 * 1024 * 1024,
100 max_stderr_bytes: 4 * 1024 * 1024,
101 address_space_bytes: 64 * 1024 * 1024 * 1024,
102 cpu_seconds: 600,
103 file_size_bytes: 64 * 1024 * 1024,
104 max_processes: 64,
105 }
106 }
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
118#[serde(deny_unknown_fields)]
119pub struct ImageMetadataLimits {
120 pub max_decompressed_bytes: u64,
125 pub max_xml_depth: u32,
127 pub max_xml_events: u64,
129 pub max_boxes: u64,
131 pub max_rows: u64,
133 pub max_output_bytes: u64,
136}
137
138impl Default for ImageMetadataLimits {
139 fn default() -> ImageMetadataLimits {
140 ImageMetadataLimits {
141 max_decompressed_bytes: 16 * 1024 * 1024,
142 max_xml_depth: 100,
143 max_xml_events: 1_000_000,
144 max_boxes: 10_000,
145 max_rows: 4096,
146 max_output_bytes: 16 * 1024 * 1024,
147 }
148 }
149}
150
151use std::collections::HashMap;
152use std::fmt;
153
154use serde::{Deserialize, Serialize};
155use unicode_normalization::UnicodeNormalization;
156
157use crate::detect::RESERVED_FORMAT_IDS;
158use crate::segments::Segment;
159use crate::{Error, Result};
160
161mod anydoc_log;
162pub mod container;
163mod differential;
164mod document;
165pub mod eml;
166pub mod html;
167pub mod pdf;
168#[cfg(all(unix, feature = "records-worker"))]
174pub(crate) mod records;
175#[cfg(all(unix, feature = "image-ocr"))]
180pub(crate) mod image_ocr;
181pub mod image_metadata;
187pub mod subprocess;
188mod visibility;
189mod workbook;
190
191pub use container::{
192 CONTAINER_ZIP_ID, CONTAINER_ZIP_VERSION, ContainerLimits, Inflated, ZipContainer,
193 preflight_entry_count,
194};
195pub use differential::{
196 DiffMetrics, DiffVerdict, DifferentialOutcome, compare_texts, normalize_for_diff,
197};
198pub use document::{AnydocDocument, markdown_to_plain};
199pub use eml::{EmlExpansion, EmlMime, expand_eml};
200pub use html::HtmlStrip;
201pub use subprocess::{PdfSubprocess, RecordsSubprocess};
202pub use visibility::{IndexRange, SheetVisibility, WorkbookVisibility, read_visibility};
203pub use workbook::WorkbookIr;
204
205#[derive(Debug, Clone)]
207pub struct Outcome {
208 pub converter_id: String,
210 pub converter_version: String,
212 pub detected_format: String,
214 pub text: String,
216 pub warnings: Vec<String>,
218 pub segments: Vec<Segment>,
220 pub artifact_kind: crate::manifest::ArtifactKind,
225}
226
227#[derive(Debug, Clone)]
229pub struct ConvertError {
230 pub code: &'static str,
232 pub message: String,
234}
235
236impl fmt::Display for ConvertError {
237 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
238 write!(f, "{}: {}", self.code, self.message)
239 }
240}
241
242impl std::error::Error for ConvertError {}
243
244pub trait Converter {
251 fn id(&self) -> &'static str;
253 fn version(&self) -> &'static str;
255 fn convert(
257 &self,
258 source: &[u8],
259 detected_format: &str,
260 ) -> std::result::Result<Outcome, ConvertError>;
261}
262
263pub fn normalize_text(input: &str) -> String {
268 let mut unified = String::with_capacity(input.len());
269 let mut chars = input.chars().peekable();
270 while let Some(c) = chars.next() {
271 if c == '\r' {
272 if chars.peek() == Some(&'\n') {
273 chars.next();
274 }
275 unified.push('\n');
276 } else {
277 unified.push(c);
278 }
279 }
280 unified.nfc().collect()
281}
282
283pub(crate) fn is_invisible_format(c: char) -> bool {
294 matches!(
295 c as u32,
296 0x00AD | 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2064 | 0xFEFF
297 )
298}
299
300pub(crate) fn is_meaningful(c: char) -> bool {
307 !c.is_whitespace() && !c.is_control() && !is_invisible_format(c)
308}
309
310pub struct PlainTextPassthrough;
325
326pub const NO_CONVERTER_REASON: &str = "no-converter";
330
331pub const RECORDS_NOT_BUILT_REASON: &str = "records-worker-not-built";
336
337pub const IMAGE_OCR_NOT_BUILT_REASON: &str = "image-ocr-not-built";
345
346pub const PASSTHROUGH_ID: &str = "text-passthrough";
348pub const PASSTHROUGH_VERSION: &str = "1.2.0";
350
351fn decode_utf16_pairs(
356 bytes: &[u8],
357 big_endian: bool,
358 mark_len: usize,
359) -> std::result::Result<String, ConvertError> {
360 if !bytes.len().is_multiple_of(2) {
361 return Err(ConvertError {
362 code: "invalid_utf16",
363 message: format!("odd trailing byte at byte {}", mark_len + bytes.len() - 1),
364 });
365 }
366 let units = bytes.chunks_exact(2).map(|pair| {
367 if big_endian {
368 u16::from_be_bytes([pair[0], pair[1]])
369 } else {
370 u16::from_le_bytes([pair[0], pair[1]])
371 }
372 });
373 let mut decoded = String::with_capacity(bytes.len() / 2);
374 let mut consumed_units = 0usize;
375 for result in char::decode_utf16(units) {
376 match result {
377 Ok(c) => {
378 decoded.push(c);
379 consumed_units += c.len_utf16();
380 }
381 Err(_) => {
382 return Err(ConvertError {
383 code: "invalid_utf16",
384 message: format!(
385 "unpaired surrogate at byte {}",
386 mark_len + consumed_units * 2
387 ),
388 });
389 }
390 }
391 }
392 Ok(decoded)
393}
394
395impl Converter for PlainTextPassthrough {
396 fn id(&self) -> &'static str {
397 PASSTHROUGH_ID
398 }
399
400 fn version(&self) -> &'static str {
401 PASSTHROUGH_VERSION
402 }
403
404 fn convert(
405 &self,
406 source: &[u8],
407 detected_format: &str,
408 ) -> std::result::Result<Outcome, ConvertError> {
409 let source_len = source.len();
410 let mut warnings = Vec::new();
411 let raw = if source.starts_with(&[0xFF, 0xFE, 0x00, 0x00])
415 || source.starts_with(&[0x00, 0x00, 0xFE, 0xFF])
416 {
417 return Err(ConvertError {
418 code: "unsupported-encoding",
419 message: "utf-32 byte order mark".to_string(),
420 });
421 } else if let Some(rest) = source.strip_prefix(&[0xFF, 0xFE][..]) {
422 warnings.push("transcoded from utf-16le".to_string());
423 decode_utf16_pairs(rest, false, 2)?
424 } else if let Some(rest) = source.strip_prefix(&[0xFE, 0xFF][..]) {
425 warnings.push("transcoded from utf-16be".to_string());
426 decode_utf16_pairs(rest, true, 2)?
427 } else {
428 let raw = String::from_utf8(source.to_vec()).map_err(|e| ConvertError {
429 code: "invalid_utf8",
430 message: format!("invalid UTF-8 at byte {}", e.utf8_error().valid_up_to()),
431 })?;
432 match raw.strip_prefix('\u{feff}') {
433 Some(stripped) => {
434 warnings.push("stripped leading byte order mark".to_string());
435 stripped.to_string()
436 }
437 None => raw,
438 }
439 };
440 if let Some(offset) = raw.find('\0') {
445 return Err(ConvertError {
446 code: "nul_bytes",
447 message: format!(
448 "NUL at byte {offset} of the decoded text, the bytes may be \
449 BOM-less UTF-16, re-export as UTF-8"
450 ),
451 });
452 }
453 let text = normalize_text(&raw);
454 if source_len > 0 && text.is_empty() {
455 return Err(ConvertError {
456 code: "empty_output",
457 message: format!("source is {source_len} bytes but conversion produced no text"),
458 });
459 }
460 let segments = vec![Segment::span(0, text.len(), "document")];
461 Ok(Outcome {
462 converter_id: PASSTHROUGH_ID.to_string(),
463 converter_version: PASSTHROUGH_VERSION.to_string(),
464 detected_format: detected_format.to_string(),
465 text,
466 warnings,
467 segments,
468 artifact_kind: crate::manifest::ArtifactKind::Text,
469 })
470 }
471}
472
473#[derive(Debug, Deserialize)]
474#[serde(deny_unknown_fields)]
475struct RawRegistry {
476 version: String,
477 converters: Vec<RawEntry>,
478 #[serde(default)]
479 unsupported: Vec<RawUnsupported>,
480 #[serde(default)]
481 containers: Option<ContainerLimits>,
482 #[serde(default)]
483 records: Option<RecordsLimits>,
484 #[serde(default)]
485 image_ocr: Option<ImageOcrLimits>,
486 #[serde(default)]
487 image_metadata: Option<ImageMetadataLimits>,
488}
489
490#[derive(Debug, Deserialize)]
491#[serde(deny_unknown_fields)]
492struct RawEntry {
493 id: String,
494 version: String,
495 formats: Vec<String>,
496}
497
498#[derive(Debug, Deserialize)]
499#[serde(deny_unknown_fields)]
500struct RawUnsupported {
501 reason: String,
502 formats: Vec<String>,
503}
504
505pub struct Registry {
511 version: String,
512 converters: Vec<Box<dyn Converter>>,
513 by_format: HashMap<String, usize>,
514 unsupported_reasons: HashMap<String, String>,
515 container_limits: ContainerLimits,
516 records_limits: RecordsLimits,
517 image_ocr_limits: ImageOcrLimits,
518 image_metadata_limits: ImageMetadataLimits,
519 image_metadata_index: Option<usize>,
524}
525
526impl Registry {
527 pub fn builtin() -> Result<Self> {
529 Self::parse(
530 include_str!("../../rules/converters.toml"),
531 "converters.toml",
532 )
533 }
534
535 pub fn parse(text: &str, name: &str) -> Result<Self> {
537 let raw: RawRegistry = toml::from_str(text).map_err(|e| Error::Rules {
538 name: name.to_string(),
539 message: e.to_string(),
540 })?;
541 let records_limits = raw.records.clone().unwrap_or_default();
542 let image_ocr_limits = raw.image_ocr.clone().unwrap_or_default();
543 let image_metadata_limits = raw.image_metadata.clone().unwrap_or_default();
544 let mut converters: Vec<Box<dyn Converter>> = Vec::new();
545 let mut by_format = HashMap::new();
546 let mut unsupported_reasons = HashMap::new();
547 #[allow(unused_mut)]
550 let mut image_metadata_index: Option<usize> = None;
551 for entry in &raw.converters {
552 #[cfg(not(feature = "records-worker"))]
559 if entry.id == subprocess::RECORDS_SUBPROCESS_ID {
560 for format in &entry.formats {
561 if RESERVED_FORMAT_IDS.contains(&format.as_str()) {
562 return Err(Error::Rules {
563 name: name.to_string(),
564 message: format!("format id {format:?} is reserved"),
565 });
566 }
567 if by_format.contains_key(format)
568 || unsupported_reasons
569 .insert(format.clone(), RECORDS_NOT_BUILT_REASON.to_string())
570 .is_some()
571 {
572 return Err(Error::Rules {
573 name: name.to_string(),
574 message: format!("format {format:?} claimed twice"),
575 });
576 }
577 }
578 continue;
579 }
580 #[cfg(not(all(unix, feature = "image-ocr")))]
587 if entry.id == subprocess::IMAGE_PIXEL_OCR_ID {
588 for format in &entry.formats {
589 if RESERVED_FORMAT_IDS.contains(&format.as_str()) {
590 return Err(Error::Rules {
591 name: name.to_string(),
592 message: format!("format id {format:?} is reserved"),
593 });
594 }
595 if by_format.contains_key(format)
596 || unsupported_reasons
597 .insert(format.clone(), IMAGE_OCR_NOT_BUILT_REASON.to_string())
598 .is_some()
599 {
600 return Err(Error::Rules {
601 name: name.to_string(),
602 message: format!("format {format:?} claimed twice"),
603 });
604 }
605 }
606 continue;
607 }
608 #[cfg(not(all(unix, feature = "image-metadata")))]
616 if entry.id == image_metadata::IMAGE_METADATA_ID {
617 continue;
618 }
619 let converter: Box<dyn Converter> = match entry.id.as_str() {
620 PASSTHROUGH_ID => Box::new(PlainTextPassthrough),
621 html::HTML_STRIP_ID => Box::new(HtmlStrip),
622 eml::EML_ID => Box::new(EmlMime),
623 document::ANYDOC_ID => Box::new(AnydocDocument),
624 workbook::WORKBOOK_ID => Box::new(WorkbookIr),
625 subprocess::PDF_SUBPROCESS_ID => Box::new(PdfSubprocess),
626 subprocess::RECORDS_SUBPROCESS_ID => {
627 Box::new(RecordsSubprocess::new(records_limits.clone()))
628 }
629 #[cfg(all(unix, feature = "image-ocr"))]
630 subprocess::IMAGE_PIXEL_OCR_ID => {
631 Box::new(subprocess::ImagePixelOcr::new(image_ocr_limits.clone()))
632 }
633 #[cfg(all(unix, feature = "image-metadata"))]
634 image_metadata::IMAGE_METADATA_ID => Box::new(subprocess::ImageMetadata::new(
635 image_metadata_limits.clone(),
636 )),
637 other => {
638 return Err(Error::Rules {
639 name: name.to_string(),
640 message: format!("unknown converter id {other:?}"),
641 });
642 }
643 };
644 if entry.version != converter.version() {
645 return Err(Error::Rules {
646 name: name.to_string(),
647 message: format!(
648 "converter {:?} pins version {:?} but the implementation is {:?}",
649 entry.id,
650 entry.version,
651 converter.version()
652 ),
653 });
654 }
655 let index = converters.len();
656 #[cfg(all(unix, feature = "image-metadata"))]
657 if entry.id == image_metadata::IMAGE_METADATA_ID {
658 image_metadata_index = Some(index);
659 }
660 converters.push(converter);
661 for format in &entry.formats {
662 if RESERVED_FORMAT_IDS.contains(&format.as_str()) {
663 return Err(Error::Rules {
664 name: name.to_string(),
665 message: format!("format id {format:?} is reserved"),
666 });
667 }
668 if by_format.insert(format.clone(), index).is_some() {
669 return Err(Error::Rules {
670 name: name.to_string(),
671 message: format!("format {format:?} claimed twice"),
672 });
673 }
674 }
675 }
676 for entry in &raw.unsupported {
677 for format in &entry.formats {
678 if RESERVED_FORMAT_IDS.contains(&format.as_str()) {
679 return Err(Error::Rules {
680 name: name.to_string(),
681 message: format!("format id {format:?} is reserved"),
682 });
683 }
684 if by_format.contains_key(format)
685 || unsupported_reasons
686 .insert(format.clone(), entry.reason.clone())
687 .is_some()
688 {
689 return Err(Error::Rules {
690 name: name.to_string(),
691 message: format!("format {format:?} claimed twice"),
692 });
693 }
694 }
695 }
696 Ok(Registry {
697 version: raw.version,
698 converters,
699 by_format,
700 unsupported_reasons,
701 container_limits: raw.containers.unwrap_or_default(),
702 records_limits,
703 image_ocr_limits,
704 image_metadata_limits,
705 image_metadata_index,
706 })
707 }
708
709 pub fn version(&self) -> &str {
711 &self.version
712 }
713
714 pub fn converter_for(&self, format_id: &str) -> Option<&dyn Converter> {
716 self.by_format
717 .get(format_id)
718 .map(|index| self.converters[*index].as_ref())
719 }
720
721 pub fn container_limits(&self) -> &ContainerLimits {
723 &self.container_limits
724 }
725
726 pub fn records_limits(&self) -> &RecordsLimits {
728 &self.records_limits
729 }
730
731 pub fn image_ocr_limits(&self) -> &ImageOcrLimits {
733 &self.image_ocr_limits
734 }
735
736 pub fn image_metadata_limits(&self) -> &ImageMetadataLimits {
738 &self.image_metadata_limits
739 }
740
741 #[cfg(all(unix, feature = "image-ocr", feature = "test-adapters"))]
748 pub fn use_fake_image_ocr(&mut self) {
749 if let Some(index) = self
750 .converters
751 .iter()
752 .position(|c| c.id() == subprocess::IMAGE_PIXEL_OCR_ID)
753 {
754 self.converters[index] = Box::new(subprocess::ImagePixelOcr::new_fake(
755 self.image_ocr_limits.clone(),
756 ));
757 }
758 }
759
760 pub fn image_metadata_converter(&self) -> Option<&dyn Converter> {
764 self.image_metadata_index
765 .map(|index| self.converters[index].as_ref())
766 }
767
768 pub fn unsupported_reason(&self, format_id: &str) -> Option<&str> {
776 if self.by_format.contains_key(format_id) {
777 return None;
778 }
779 Some(
780 self.unsupported_reasons
781 .get(format_id)
782 .map(String::as_str)
783 .unwrap_or(NO_CONVERTER_REASON),
784 )
785 }
786
787 #[cfg(test)]
791 pub(crate) fn for_tests(
792 version: &str,
793 entries: Vec<(Box<dyn Converter>, Vec<&str>)>,
794 ) -> Registry {
795 let mut converters = Vec::new();
796 let mut by_format = HashMap::new();
797 for (converter, formats) in entries {
798 let index = converters.len();
799 converters.push(converter);
800 for format in formats {
801 by_format.insert(format.to_string(), index);
802 }
803 }
804 Registry {
805 version: version.to_string(),
806 converters,
807 by_format,
808 unsupported_reasons: HashMap::new(),
809 container_limits: ContainerLimits::default(),
810 records_limits: RecordsLimits::default(),
811 image_ocr_limits: ImageOcrLimits::default(),
812 image_metadata_limits: ImageMetadataLimits::default(),
813 image_metadata_index: None,
814 }
815 }
816}
817
818#[cfg(test)]
819mod tests {
820 use super::*;
821
822 #[test]
823 fn normalize_converts_crlf_to_lf() {
824 assert_eq!(normalize_text("a\r\nb\r\n"), "a\nb\n");
825 }
826
827 #[test]
828 fn normalize_converts_lone_cr_to_lf() {
829 assert_eq!(normalize_text("a\rb\r"), "a\nb\n");
830 assert_eq!(normalize_text("a\r\rb"), "a\n\nb");
831 }
832
833 #[test]
834 fn normalize_composes_to_nfc() {
835 assert_eq!(normalize_text("cafe\u{301}"), "caf\u{e9}");
836 }
837
838 #[test]
839 fn normalize_never_applies_nfkc() {
840 assert_eq!(normalize_text("\u{fb01}le \u{2460}"), "\u{fb01}le \u{2460}");
841 }
842
843 #[test]
844 fn passthrough_rejects_invalid_utf8() {
845 let err = PlainTextPassthrough
846 .convert(b"hello \xff world", "text")
847 .unwrap_err();
848 assert_eq!(err.code, "invalid_utf8");
849 }
850
851 #[test]
852 fn passthrough_fails_on_suspiciously_empty_output() {
853 let err = PlainTextPassthrough
854 .convert("\u{feff}".as_bytes(), "text")
855 .unwrap_err();
856 assert_eq!(err.code, "empty_output");
857 }
858
859 #[test]
860 fn passthrough_accepts_an_empty_source() {
861 let outcome = PlainTextPassthrough.convert(b"", "text").unwrap();
862 assert_eq!(outcome.text, "");
863 }
864
865 fn utf16_bytes(text: &str, big_endian: bool) -> Vec<u8> {
866 let mut bytes = if big_endian {
867 vec![0xFE, 0xFF]
868 } else {
869 vec![0xFF, 0xFE]
870 };
871 for unit in text.encode_utf16() {
872 bytes.extend_from_slice(&if big_endian {
873 unit.to_be_bytes()
874 } else {
875 unit.to_le_bytes()
876 });
877 }
878 bytes
879 }
880
881 #[test]
882 fn passthrough_transcodes_utf16le_behind_a_byte_order_mark() {
883 let source = utf16_bytes("a,b\r\n1,caf\u{e9}\r\n", false);
884 let outcome = PlainTextPassthrough.convert(&source, "csv").unwrap();
885 assert_eq!(outcome.text, "a,b\n1,caf\u{e9}\n");
886 assert_eq!(
887 outcome.warnings,
888 vec!["transcoded from utf-16le".to_string()]
889 );
890 }
891
892 #[test]
893 fn passthrough_transcodes_utf16be_behind_a_byte_order_mark() {
894 let source = utf16_bytes("total \u{5317}\u{4eac}\n", true);
895 let outcome = PlainTextPassthrough.convert(&source, "text").unwrap();
896 assert_eq!(outcome.text, "total \u{5317}\u{4eac}\n");
897 assert_eq!(
898 outcome.warnings,
899 vec!["transcoded from utf-16be".to_string()]
900 );
901 }
902
903 #[test]
904 fn passthrough_refuses_utf32_byte_order_marks() {
905 for source in [
906 b"\xFF\xFE\x00\x00A\x00\x00\x00".as_slice(),
907 b"\x00\x00\xFE\xFF\x00\x00\x00A".as_slice(),
908 ] {
909 let err = PlainTextPassthrough.convert(source, "text").unwrap_err();
910 assert_eq!(err.code, "unsupported-encoding");
911 assert!(err.to_string().starts_with("unsupported-encoding: utf-32"));
912 }
913 }
914
915 #[test]
916 fn passthrough_fails_an_odd_utf16_tail_at_its_offset() {
917 let err = PlainTextPassthrough
918 .convert(b"\xFF\xFEA\x00B", "text")
919 .unwrap_err();
920 assert_eq!(err.code, "invalid_utf16");
921 assert_eq!(err.message, "odd trailing byte at byte 4");
922 }
923
924 #[test]
925 fn passthrough_fails_an_unpaired_surrogate_at_its_offset() {
926 let err = PlainTextPassthrough
927 .convert(b"\xFF\xFEA\x00\x00\xD8", "text")
928 .unwrap_err();
929 assert_eq!(err.code, "invalid_utf16");
930 assert_eq!(err.message, "unpaired surrogate at byte 4");
931 }
932
933 #[test]
934 fn passthrough_refuses_nul_bytes_with_the_first_offset() {
935 let err = PlainTextPassthrough
936 .convert(b"ab\0cd\0", "text")
937 .unwrap_err();
938 assert_eq!(err.code, "nul_bytes");
939 assert!(err.message.contains("byte 2"), "{}", err.message);
940 assert!(err.message.contains("BOM-less UTF-16"), "{}", err.message);
941 let err = PlainTextPassthrough
944 .convert(b"\xFF\xFEA\x00\x00\x00B\x00", "text")
945 .unwrap_err();
946 assert_eq!(err.code, "nul_bytes");
947 assert!(err.message.contains("byte 1"), "{}", err.message);
948 }
949
950 #[test]
951 fn passthrough_still_fails_bomless_utf16_as_invalid_utf8() {
952 let mut source = utf16_bytes("R\u{e9}sum\u{e9}\n", false);
953 source.drain(..2);
954 let err = PlainTextPassthrough.convert(&source, "text").unwrap_err();
955 assert_eq!(err.code, "invalid_utf8");
956 }
957
958 #[test]
959 fn builtin_registry_claims_the_text_family() {
960 let registry = Registry::builtin().unwrap();
961 assert_eq!(registry.version(), "9");
962 assert!(registry.converter_for("json").is_some());
963 assert!(registry.converter_for("yaml").is_some());
964 assert!(registry.converter_for("svg").is_some());
965 assert!(registry.converter_for("ipynb").is_some());
966 assert!(registry.converter_for("python").is_some());
967 assert!(registry.converter_for("pickle").is_none());
968 assert!(registry.converter_for("text").is_some());
969 assert!(registry.converter_for("markdown").is_some());
970 assert!(registry.converter_for("csv").is_some());
971 assert!(registry.converter_for("pdf").is_some());
972 assert_eq!(
973 registry.converter_for("pdf").map(|c| c.id()),
974 Some("pdf-subprocess")
975 );
976 assert!(registry.converter_for("docx").is_some());
977 assert!(registry.converter_for("xlsx").is_some());
978 assert_eq!(
979 registry.converter_for("html").map(|c| c.id()),
980 Some("html-strip")
981 );
982 assert_eq!(
983 registry.converter_for("eml").map(|c| c.id()),
984 Some("eml-mime")
985 );
986 assert!(registry.converter_for("msg").is_none());
987 assert!(registry.converter_for("xlsb").is_none());
988 assert_eq!(
989 registry.unsupported_reason("xlsb"),
990 Some("hidden-visibility-unresolved")
991 );
992 assert_eq!(
993 registry.unsupported_reason("pickle"),
994 Some("pickle-deserialization-unsafe")
995 );
996 for format in ["png", "jpeg", "webp"] {
1001 assert_eq!(
1002 registry.converter_for(format).map(|c| c.id()),
1003 Some("image-pixel-ocr"),
1004 "{format}"
1005 );
1006 assert_eq!(registry.unsupported_reason(format), None, "{format}");
1007 }
1008 assert_eq!(registry.unsupported_reason("tiff"), Some("engine-unpinned"));
1009 assert_eq!(
1012 registry.unsupported_reason("heic"),
1013 Some("no-jailed-rasterizer")
1014 );
1015 assert_eq!(
1019 registry.converter_for("ai").map(|c| c.id()),
1020 Some("pdf-subprocess")
1021 );
1022 for format in ["parquet", "avro", "sqlite"] {
1026 assert_eq!(
1027 registry.converter_for(format).map(|c| c.id()),
1028 Some("records-worker"),
1029 "{format}"
1030 );
1031 assert_eq!(registry.unsupported_reason(format), None, "{format}");
1032 }
1033 assert_eq!(
1034 registry.unsupported_reason("arrow"),
1035 Some("converter-deferred")
1036 );
1037 assert_eq!(
1038 registry.unsupported_reason("tar"),
1039 Some("container-deferred")
1040 );
1041 assert_eq!(
1042 registry.unsupported_reason("psd"),
1043 Some("proprietary-binary")
1044 );
1045 assert_eq!(
1047 registry.unsupported_reason("unknown"),
1048 Some(NO_CONVERTER_REASON)
1049 );
1050 assert_eq!(registry.unsupported_reason("text"), None);
1052 assert_eq!(registry.unsupported_reason("mp4"), Some("engine-unpinned"));
1053 assert_eq!(registry.unsupported_reason("html"), None);
1056 assert_eq!(
1057 registry.unsupported_reason("msg"),
1058 Some(NO_CONVERTER_REASON)
1059 );
1060 }
1061
1062 #[test]
1077 fn the_raster_family_routes_by_the_image_ocr_feature() {
1078 let registry = Registry::builtin().unwrap();
1079 let feature_present = cfg!(all(unix, feature = "image-ocr"));
1080 for format in ["png", "jpeg", "webp"] {
1081 if feature_present {
1082 assert!(registry.converter_for(format).is_some(), "{format}");
1085 assert_eq!(registry.unsupported_reason(format), None, "{format}");
1086 } else {
1087 assert!(registry.converter_for(format).is_none(), "{format}");
1090 assert_eq!(
1091 registry.unsupported_reason(format),
1092 Some(IMAGE_OCR_NOT_BUILT_REASON),
1093 "{format}"
1094 );
1095 }
1096 }
1097 }
1098
1099 #[test]
1100 fn registry_rejects_reserved_format_ids() {
1101 for (section, id) in [
1102 ("converters", "symlink"),
1103 ("converters", "unknown"),
1104 ("unsupported", "symlink"),
1105 ("unsupported", "unknown"),
1106 ] {
1107 let toml = if section == "converters" {
1108 format!(
1109 "version = \"1\"\n[[converters]]\nid = \"text-passthrough\"\nversion = \"1.2.0\"\nformats = [\"{id}\"]\n"
1110 )
1111 } else {
1112 format!(
1113 "version = \"1\"\nconverters = []\n[[unsupported]]\nreason = \"r\"\nformats = [\"{id}\"]\n"
1114 )
1115 };
1116 let err = match Registry::parse(&toml, "converters.toml") {
1117 Ok(_) => panic!("{section} {id}: reserved id was accepted"),
1118 Err(e) => e,
1119 };
1120 assert!(
1121 err.to_string().contains("reserved"),
1122 "{section} {id}: {err}"
1123 );
1124 }
1125 }
1126
1127 #[test]
1128 fn registry_rejects_a_version_that_contradicts_the_implementation() {
1129 let toml = r#"
1130version = "1"
1131
1132[[converters]]
1133id = "text-passthrough"
1134version = "9.9.9"
1135formats = ["text"]
1136"#;
1137 assert!(Registry::parse(toml, "converters.toml").is_err());
1138 }
1139}