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
69use std::collections::HashMap;
70use std::fmt;
71
72use serde::{Deserialize, Serialize};
73use unicode_normalization::UnicodeNormalization;
74
75use crate::detect::RESERVED_FORMAT_IDS;
76use crate::segments::Segment;
77use crate::{Error, Result};
78
79mod anydoc_log;
80pub mod container;
81mod differential;
82mod document;
83pub mod eml;
84pub mod html;
85pub mod pdf;
86#[cfg(all(unix, feature = "records-worker"))]
92pub(crate) mod records;
93pub mod subprocess;
94mod visibility;
95mod workbook;
96
97pub use container::{
98 CONTAINER_ZIP_ID, CONTAINER_ZIP_VERSION, ContainerLimits, Inflated, ZipContainer,
99 preflight_entry_count,
100};
101pub use differential::{
102 DiffMetrics, DiffVerdict, DifferentialOutcome, compare_texts, normalize_for_diff,
103};
104pub use document::{AnydocDocument, markdown_to_plain};
105pub use eml::{EmlExpansion, EmlMime, expand_eml};
106pub use html::HtmlStrip;
107pub use subprocess::{PdfSubprocess, RecordsSubprocess};
108pub use visibility::{IndexRange, SheetVisibility, WorkbookVisibility, read_visibility};
109pub use workbook::WorkbookIr;
110
111#[derive(Debug, Clone)]
113pub struct Outcome {
114 pub converter_id: String,
116 pub converter_version: String,
118 pub detected_format: String,
120 pub text: String,
122 pub warnings: Vec<String>,
124 pub segments: Vec<Segment>,
126}
127
128#[derive(Debug, Clone)]
130pub struct ConvertError {
131 pub code: &'static str,
133 pub message: String,
135}
136
137impl fmt::Display for ConvertError {
138 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139 write!(f, "{}: {}", self.code, self.message)
140 }
141}
142
143impl std::error::Error for ConvertError {}
144
145pub trait Converter {
152 fn id(&self) -> &'static str;
154 fn version(&self) -> &'static str;
156 fn convert(
158 &self,
159 source: &[u8],
160 detected_format: &str,
161 ) -> std::result::Result<Outcome, ConvertError>;
162}
163
164pub fn normalize_text(input: &str) -> String {
169 let mut unified = String::with_capacity(input.len());
170 let mut chars = input.chars().peekable();
171 while let Some(c) = chars.next() {
172 if c == '\r' {
173 if chars.peek() == Some(&'\n') {
174 chars.next();
175 }
176 unified.push('\n');
177 } else {
178 unified.push(c);
179 }
180 }
181 unified.nfc().collect()
182}
183
184pub struct PlainTextPassthrough;
199
200pub const NO_CONVERTER_REASON: &str = "no-converter";
204
205pub const RECORDS_NOT_BUILT_REASON: &str = "records-worker-not-built";
210
211pub const PASSTHROUGH_ID: &str = "text-passthrough";
213pub const PASSTHROUGH_VERSION: &str = "1.2.0";
215
216fn decode_utf16_pairs(
221 bytes: &[u8],
222 big_endian: bool,
223 mark_len: usize,
224) -> std::result::Result<String, ConvertError> {
225 if !bytes.len().is_multiple_of(2) {
226 return Err(ConvertError {
227 code: "invalid_utf16",
228 message: format!("odd trailing byte at byte {}", mark_len + bytes.len() - 1),
229 });
230 }
231 let units = bytes.chunks_exact(2).map(|pair| {
232 if big_endian {
233 u16::from_be_bytes([pair[0], pair[1]])
234 } else {
235 u16::from_le_bytes([pair[0], pair[1]])
236 }
237 });
238 let mut decoded = String::with_capacity(bytes.len() / 2);
239 let mut consumed_units = 0usize;
240 for result in char::decode_utf16(units) {
241 match result {
242 Ok(c) => {
243 decoded.push(c);
244 consumed_units += c.len_utf16();
245 }
246 Err(_) => {
247 return Err(ConvertError {
248 code: "invalid_utf16",
249 message: format!(
250 "unpaired surrogate at byte {}",
251 mark_len + consumed_units * 2
252 ),
253 });
254 }
255 }
256 }
257 Ok(decoded)
258}
259
260impl Converter for PlainTextPassthrough {
261 fn id(&self) -> &'static str {
262 PASSTHROUGH_ID
263 }
264
265 fn version(&self) -> &'static str {
266 PASSTHROUGH_VERSION
267 }
268
269 fn convert(
270 &self,
271 source: &[u8],
272 detected_format: &str,
273 ) -> std::result::Result<Outcome, ConvertError> {
274 let source_len = source.len();
275 let mut warnings = Vec::new();
276 let raw = if source.starts_with(&[0xFF, 0xFE, 0x00, 0x00])
280 || source.starts_with(&[0x00, 0x00, 0xFE, 0xFF])
281 {
282 return Err(ConvertError {
283 code: "unsupported-encoding",
284 message: "utf-32 byte order mark".to_string(),
285 });
286 } else if let Some(rest) = source.strip_prefix(&[0xFF, 0xFE][..]) {
287 warnings.push("transcoded from utf-16le".to_string());
288 decode_utf16_pairs(rest, false, 2)?
289 } else if let Some(rest) = source.strip_prefix(&[0xFE, 0xFF][..]) {
290 warnings.push("transcoded from utf-16be".to_string());
291 decode_utf16_pairs(rest, true, 2)?
292 } else {
293 let raw = String::from_utf8(source.to_vec()).map_err(|e| ConvertError {
294 code: "invalid_utf8",
295 message: format!("invalid UTF-8 at byte {}", e.utf8_error().valid_up_to()),
296 })?;
297 match raw.strip_prefix('\u{feff}') {
298 Some(stripped) => {
299 warnings.push("stripped leading byte order mark".to_string());
300 stripped.to_string()
301 }
302 None => raw,
303 }
304 };
305 if let Some(offset) = raw.find('\0') {
310 return Err(ConvertError {
311 code: "nul_bytes",
312 message: format!(
313 "NUL at byte {offset} of the decoded text, the bytes may be \
314 BOM-less UTF-16, re-export as UTF-8"
315 ),
316 });
317 }
318 let text = normalize_text(&raw);
319 if source_len > 0 && text.is_empty() {
320 return Err(ConvertError {
321 code: "empty_output",
322 message: format!("source is {source_len} bytes but conversion produced no text"),
323 });
324 }
325 let segments = vec![Segment::span(0, text.len(), "document")];
326 Ok(Outcome {
327 converter_id: PASSTHROUGH_ID.to_string(),
328 converter_version: PASSTHROUGH_VERSION.to_string(),
329 detected_format: detected_format.to_string(),
330 text,
331 warnings,
332 segments,
333 })
334 }
335}
336
337#[derive(Debug, Deserialize)]
338#[serde(deny_unknown_fields)]
339struct RawRegistry {
340 version: String,
341 converters: Vec<RawEntry>,
342 #[serde(default)]
343 unsupported: Vec<RawUnsupported>,
344 #[serde(default)]
345 containers: Option<ContainerLimits>,
346 #[serde(default)]
347 records: Option<RecordsLimits>,
348}
349
350#[derive(Debug, Deserialize)]
351#[serde(deny_unknown_fields)]
352struct RawEntry {
353 id: String,
354 version: String,
355 formats: Vec<String>,
356}
357
358#[derive(Debug, Deserialize)]
359#[serde(deny_unknown_fields)]
360struct RawUnsupported {
361 reason: String,
362 formats: Vec<String>,
363}
364
365pub struct Registry {
371 version: String,
372 converters: Vec<Box<dyn Converter>>,
373 by_format: HashMap<String, usize>,
374 unsupported_reasons: HashMap<String, String>,
375 container_limits: ContainerLimits,
376 records_limits: RecordsLimits,
377}
378
379impl Registry {
380 pub fn builtin() -> Result<Self> {
382 Self::parse(
383 include_str!("../../rules/converters.toml"),
384 "converters.toml",
385 )
386 }
387
388 pub fn parse(text: &str, name: &str) -> Result<Self> {
390 let raw: RawRegistry = toml::from_str(text).map_err(|e| Error::Rules {
391 name: name.to_string(),
392 message: e.to_string(),
393 })?;
394 let records_limits = raw.records.clone().unwrap_or_default();
395 let mut converters: Vec<Box<dyn Converter>> = Vec::new();
396 let mut by_format = HashMap::new();
397 let mut unsupported_reasons = HashMap::new();
398 for entry in &raw.converters {
399 #[cfg(not(feature = "records-worker"))]
406 if entry.id == subprocess::RECORDS_SUBPROCESS_ID {
407 for format in &entry.formats {
408 if RESERVED_FORMAT_IDS.contains(&format.as_str()) {
409 return Err(Error::Rules {
410 name: name.to_string(),
411 message: format!("format id {format:?} is reserved"),
412 });
413 }
414 if by_format.contains_key(format)
415 || unsupported_reasons
416 .insert(format.clone(), RECORDS_NOT_BUILT_REASON.to_string())
417 .is_some()
418 {
419 return Err(Error::Rules {
420 name: name.to_string(),
421 message: format!("format {format:?} claimed twice"),
422 });
423 }
424 }
425 continue;
426 }
427 let converter: Box<dyn Converter> = match entry.id.as_str() {
428 PASSTHROUGH_ID => Box::new(PlainTextPassthrough),
429 html::HTML_STRIP_ID => Box::new(HtmlStrip),
430 eml::EML_ID => Box::new(EmlMime),
431 document::ANYDOC_ID => Box::new(AnydocDocument),
432 workbook::WORKBOOK_ID => Box::new(WorkbookIr),
433 subprocess::PDF_SUBPROCESS_ID => Box::new(PdfSubprocess),
434 subprocess::RECORDS_SUBPROCESS_ID => {
435 Box::new(RecordsSubprocess::new(records_limits.clone()))
436 }
437 other => {
438 return Err(Error::Rules {
439 name: name.to_string(),
440 message: format!("unknown converter id {other:?}"),
441 });
442 }
443 };
444 if entry.version != converter.version() {
445 return Err(Error::Rules {
446 name: name.to_string(),
447 message: format!(
448 "converter {:?} pins version {:?} but the implementation is {:?}",
449 entry.id,
450 entry.version,
451 converter.version()
452 ),
453 });
454 }
455 let index = converters.len();
456 converters.push(converter);
457 for format in &entry.formats {
458 if RESERVED_FORMAT_IDS.contains(&format.as_str()) {
459 return Err(Error::Rules {
460 name: name.to_string(),
461 message: format!("format id {format:?} is reserved"),
462 });
463 }
464 if by_format.insert(format.clone(), index).is_some() {
465 return Err(Error::Rules {
466 name: name.to_string(),
467 message: format!("format {format:?} claimed twice"),
468 });
469 }
470 }
471 }
472 for entry in &raw.unsupported {
473 for format in &entry.formats {
474 if RESERVED_FORMAT_IDS.contains(&format.as_str()) {
475 return Err(Error::Rules {
476 name: name.to_string(),
477 message: format!("format id {format:?} is reserved"),
478 });
479 }
480 if by_format.contains_key(format)
481 || unsupported_reasons
482 .insert(format.clone(), entry.reason.clone())
483 .is_some()
484 {
485 return Err(Error::Rules {
486 name: name.to_string(),
487 message: format!("format {format:?} claimed twice"),
488 });
489 }
490 }
491 }
492 Ok(Registry {
493 version: raw.version,
494 converters,
495 by_format,
496 unsupported_reasons,
497 container_limits: raw.containers.unwrap_or_default(),
498 records_limits,
499 })
500 }
501
502 pub fn version(&self) -> &str {
504 &self.version
505 }
506
507 pub fn converter_for(&self, format_id: &str) -> Option<&dyn Converter> {
509 self.by_format
510 .get(format_id)
511 .map(|index| self.converters[*index].as_ref())
512 }
513
514 pub fn container_limits(&self) -> &ContainerLimits {
516 &self.container_limits
517 }
518
519 pub fn records_limits(&self) -> &RecordsLimits {
521 &self.records_limits
522 }
523
524 pub fn unsupported_reason(&self, format_id: &str) -> Option<&str> {
532 if self.by_format.contains_key(format_id) {
533 return None;
534 }
535 Some(
536 self.unsupported_reasons
537 .get(format_id)
538 .map(String::as_str)
539 .unwrap_or(NO_CONVERTER_REASON),
540 )
541 }
542
543 #[cfg(test)]
547 pub(crate) fn for_tests(
548 version: &str,
549 entries: Vec<(Box<dyn Converter>, Vec<&str>)>,
550 ) -> Registry {
551 let mut converters = Vec::new();
552 let mut by_format = HashMap::new();
553 for (converter, formats) in entries {
554 let index = converters.len();
555 converters.push(converter);
556 for format in formats {
557 by_format.insert(format.to_string(), index);
558 }
559 }
560 Registry {
561 version: version.to_string(),
562 converters,
563 by_format,
564 unsupported_reasons: HashMap::new(),
565 container_limits: ContainerLimits::default(),
566 records_limits: RecordsLimits::default(),
567 }
568 }
569}
570
571#[cfg(test)]
572mod tests {
573 use super::*;
574
575 #[test]
576 fn normalize_converts_crlf_to_lf() {
577 assert_eq!(normalize_text("a\r\nb\r\n"), "a\nb\n");
578 }
579
580 #[test]
581 fn normalize_converts_lone_cr_to_lf() {
582 assert_eq!(normalize_text("a\rb\r"), "a\nb\n");
583 assert_eq!(normalize_text("a\r\rb"), "a\n\nb");
584 }
585
586 #[test]
587 fn normalize_composes_to_nfc() {
588 assert_eq!(normalize_text("cafe\u{301}"), "caf\u{e9}");
589 }
590
591 #[test]
592 fn normalize_never_applies_nfkc() {
593 assert_eq!(normalize_text("\u{fb01}le \u{2460}"), "\u{fb01}le \u{2460}");
594 }
595
596 #[test]
597 fn passthrough_rejects_invalid_utf8() {
598 let err = PlainTextPassthrough
599 .convert(b"hello \xff world", "text")
600 .unwrap_err();
601 assert_eq!(err.code, "invalid_utf8");
602 }
603
604 #[test]
605 fn passthrough_fails_on_suspiciously_empty_output() {
606 let err = PlainTextPassthrough
607 .convert("\u{feff}".as_bytes(), "text")
608 .unwrap_err();
609 assert_eq!(err.code, "empty_output");
610 }
611
612 #[test]
613 fn passthrough_accepts_an_empty_source() {
614 let outcome = PlainTextPassthrough.convert(b"", "text").unwrap();
615 assert_eq!(outcome.text, "");
616 }
617
618 fn utf16_bytes(text: &str, big_endian: bool) -> Vec<u8> {
619 let mut bytes = if big_endian {
620 vec![0xFE, 0xFF]
621 } else {
622 vec![0xFF, 0xFE]
623 };
624 for unit in text.encode_utf16() {
625 bytes.extend_from_slice(&if big_endian {
626 unit.to_be_bytes()
627 } else {
628 unit.to_le_bytes()
629 });
630 }
631 bytes
632 }
633
634 #[test]
635 fn passthrough_transcodes_utf16le_behind_a_byte_order_mark() {
636 let source = utf16_bytes("a,b\r\n1,caf\u{e9}\r\n", false);
637 let outcome = PlainTextPassthrough.convert(&source, "csv").unwrap();
638 assert_eq!(outcome.text, "a,b\n1,caf\u{e9}\n");
639 assert_eq!(
640 outcome.warnings,
641 vec!["transcoded from utf-16le".to_string()]
642 );
643 }
644
645 #[test]
646 fn passthrough_transcodes_utf16be_behind_a_byte_order_mark() {
647 let source = utf16_bytes("total \u{5317}\u{4eac}\n", true);
648 let outcome = PlainTextPassthrough.convert(&source, "text").unwrap();
649 assert_eq!(outcome.text, "total \u{5317}\u{4eac}\n");
650 assert_eq!(
651 outcome.warnings,
652 vec!["transcoded from utf-16be".to_string()]
653 );
654 }
655
656 #[test]
657 fn passthrough_refuses_utf32_byte_order_marks() {
658 for source in [
659 b"\xFF\xFE\x00\x00A\x00\x00\x00".as_slice(),
660 b"\x00\x00\xFE\xFF\x00\x00\x00A".as_slice(),
661 ] {
662 let err = PlainTextPassthrough.convert(source, "text").unwrap_err();
663 assert_eq!(err.code, "unsupported-encoding");
664 assert!(err.to_string().starts_with("unsupported-encoding: utf-32"));
665 }
666 }
667
668 #[test]
669 fn passthrough_fails_an_odd_utf16_tail_at_its_offset() {
670 let err = PlainTextPassthrough
671 .convert(b"\xFF\xFEA\x00B", "text")
672 .unwrap_err();
673 assert_eq!(err.code, "invalid_utf16");
674 assert_eq!(err.message, "odd trailing byte at byte 4");
675 }
676
677 #[test]
678 fn passthrough_fails_an_unpaired_surrogate_at_its_offset() {
679 let err = PlainTextPassthrough
680 .convert(b"\xFF\xFEA\x00\x00\xD8", "text")
681 .unwrap_err();
682 assert_eq!(err.code, "invalid_utf16");
683 assert_eq!(err.message, "unpaired surrogate at byte 4");
684 }
685
686 #[test]
687 fn passthrough_refuses_nul_bytes_with_the_first_offset() {
688 let err = PlainTextPassthrough
689 .convert(b"ab\0cd\0", "text")
690 .unwrap_err();
691 assert_eq!(err.code, "nul_bytes");
692 assert!(err.message.contains("byte 2"), "{}", err.message);
693 assert!(err.message.contains("BOM-less UTF-16"), "{}", err.message);
694 let err = PlainTextPassthrough
697 .convert(b"\xFF\xFEA\x00\x00\x00B\x00", "text")
698 .unwrap_err();
699 assert_eq!(err.code, "nul_bytes");
700 assert!(err.message.contains("byte 1"), "{}", err.message);
701 }
702
703 #[test]
704 fn passthrough_still_fails_bomless_utf16_as_invalid_utf8() {
705 let mut source = utf16_bytes("R\u{e9}sum\u{e9}\n", false);
706 source.drain(..2);
707 let err = PlainTextPassthrough.convert(&source, "text").unwrap_err();
708 assert_eq!(err.code, "invalid_utf8");
709 }
710
711 #[test]
712 fn builtin_registry_claims_the_text_family() {
713 let registry = Registry::builtin().unwrap();
714 assert_eq!(registry.version(), "7");
715 assert!(registry.converter_for("json").is_some());
716 assert!(registry.converter_for("yaml").is_some());
717 assert!(registry.converter_for("svg").is_some());
718 assert!(registry.converter_for("ipynb").is_some());
719 assert!(registry.converter_for("python").is_some());
720 assert!(registry.converter_for("pickle").is_none());
721 assert!(registry.converter_for("text").is_some());
722 assert!(registry.converter_for("markdown").is_some());
723 assert!(registry.converter_for("csv").is_some());
724 assert!(registry.converter_for("pdf").is_some());
725 assert_eq!(
726 registry.converter_for("pdf").map(|c| c.id()),
727 Some("pdf-subprocess")
728 );
729 assert!(registry.converter_for("docx").is_some());
730 assert!(registry.converter_for("xlsx").is_some());
731 assert_eq!(
732 registry.converter_for("html").map(|c| c.id()),
733 Some("html-strip")
734 );
735 assert_eq!(
736 registry.converter_for("eml").map(|c| c.id()),
737 Some("eml-mime")
738 );
739 assert!(registry.converter_for("msg").is_none());
740 assert!(registry.converter_for("xlsb").is_none());
741 assert_eq!(
742 registry.unsupported_reason("xlsb"),
743 Some("hidden-visibility-unresolved")
744 );
745 assert_eq!(
746 registry.unsupported_reason("pickle"),
747 Some("pickle-deserialization-unsafe")
748 );
749 assert_eq!(registry.unsupported_reason("webp"), Some("engine-unpinned"));
750 for format in ["parquet", "avro", "sqlite"] {
754 assert_eq!(
755 registry.converter_for(format).map(|c| c.id()),
756 Some("records-worker"),
757 "{format}"
758 );
759 assert_eq!(registry.unsupported_reason(format), None, "{format}");
760 }
761 assert_eq!(
762 registry.unsupported_reason("arrow"),
763 Some("converter-deferred")
764 );
765 assert_eq!(
766 registry.unsupported_reason("tar"),
767 Some("container-deferred")
768 );
769 assert_eq!(
770 registry.unsupported_reason("psd"),
771 Some("proprietary-binary")
772 );
773 assert_eq!(
775 registry.unsupported_reason("unknown"),
776 Some(NO_CONVERTER_REASON)
777 );
778 assert_eq!(registry.unsupported_reason("text"), None);
780 assert_eq!(registry.unsupported_reason("png"), Some("engine-unpinned"));
781 assert_eq!(registry.unsupported_reason("mp4"), Some("engine-unpinned"));
782 assert_eq!(registry.unsupported_reason("html"), None);
785 assert_eq!(
786 registry.unsupported_reason("msg"),
787 Some(NO_CONVERTER_REASON)
788 );
789 }
790
791 #[test]
792 fn registry_rejects_reserved_format_ids() {
793 for (section, id) in [
794 ("converters", "symlink"),
795 ("converters", "unknown"),
796 ("unsupported", "symlink"),
797 ("unsupported", "unknown"),
798 ] {
799 let toml = if section == "converters" {
800 format!(
801 "version = \"1\"\n[[converters]]\nid = \"text-passthrough\"\nversion = \"1.2.0\"\nformats = [\"{id}\"]\n"
802 )
803 } else {
804 format!(
805 "version = \"1\"\nconverters = []\n[[unsupported]]\nreason = \"r\"\nformats = [\"{id}\"]\n"
806 )
807 };
808 let err = match Registry::parse(&toml, "converters.toml") {
809 Ok(_) => panic!("{section} {id}: reserved id was accepted"),
810 Err(e) => e,
811 };
812 assert!(
813 err.to_string().contains("reserved"),
814 "{section} {id}: {err}"
815 );
816 }
817 }
818
819 #[test]
820 fn registry_rejects_a_version_that_contradicts_the_implementation() {
821 let toml = r#"
822version = "1"
823
824[[converters]]
825id = "text-passthrough"
826version = "9.9.9"
827formats = ["text"]
828"#;
829 assert!(Registry::parse(toml, "converters.toml").is_err());
830 }
831}