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
43use std::collections::HashMap;
44use std::fmt;
45
46use serde::Deserialize;
47use unicode_normalization::UnicodeNormalization;
48
49use crate::detect::RESERVED_FORMAT_IDS;
50use crate::segments::Segment;
51use crate::{Error, Result};
52
53mod anydoc_log;
54mod differential;
55mod document;
56pub mod pdf;
57pub mod subprocess;
58mod visibility;
59mod workbook;
60
61pub use differential::{
62 DiffMetrics, DiffVerdict, DifferentialOutcome, compare_texts, normalize_for_diff,
63};
64pub use document::{AnydocDocument, markdown_to_plain};
65pub use subprocess::PdfSubprocess;
66pub use visibility::{IndexRange, SheetVisibility, WorkbookVisibility, read_visibility};
67pub use workbook::WorkbookIr;
68
69#[derive(Debug, Clone)]
71pub struct Outcome {
72 pub converter_id: String,
74 pub converter_version: String,
76 pub detected_format: String,
78 pub text: String,
80 pub warnings: Vec<String>,
82 pub segments: Vec<Segment>,
84}
85
86#[derive(Debug, Clone)]
88pub struct ConvertError {
89 pub code: &'static str,
91 pub message: String,
93}
94
95impl fmt::Display for ConvertError {
96 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97 write!(f, "{}: {}", self.code, self.message)
98 }
99}
100
101impl std::error::Error for ConvertError {}
102
103pub trait Converter {
110 fn id(&self) -> &'static str;
112 fn version(&self) -> &'static str;
114 fn convert(
116 &self,
117 source: &[u8],
118 detected_format: &str,
119 ) -> std::result::Result<Outcome, ConvertError>;
120}
121
122pub fn normalize_text(input: &str) -> String {
127 let mut unified = String::with_capacity(input.len());
128 let mut chars = input.chars().peekable();
129 while let Some(c) = chars.next() {
130 if c == '\r' {
131 if chars.peek() == Some(&'\n') {
132 chars.next();
133 }
134 unified.push('\n');
135 } else {
136 unified.push(c);
137 }
138 }
139 unified.nfc().collect()
140}
141
142pub struct PlainTextPassthrough;
154
155pub const NO_CONVERTER_REASON: &str = "no-converter";
159
160pub const PASSTHROUGH_ID: &str = "text-passthrough";
162pub const PASSTHROUGH_VERSION: &str = "1.1.0";
164
165fn decode_utf16_pairs(
170 bytes: &[u8],
171 big_endian: bool,
172 mark_len: usize,
173) -> std::result::Result<String, ConvertError> {
174 if bytes.len() % 2 != 0 {
175 return Err(ConvertError {
176 code: "invalid_utf16",
177 message: format!("odd trailing byte at byte {}", mark_len + bytes.len() - 1),
178 });
179 }
180 let units = bytes.chunks_exact(2).map(|pair| {
181 if big_endian {
182 u16::from_be_bytes([pair[0], pair[1]])
183 } else {
184 u16::from_le_bytes([pair[0], pair[1]])
185 }
186 });
187 let mut decoded = String::with_capacity(bytes.len() / 2);
188 let mut consumed_units = 0usize;
189 for result in char::decode_utf16(units) {
190 match result {
191 Ok(c) => {
192 decoded.push(c);
193 consumed_units += c.len_utf16();
194 }
195 Err(_) => {
196 return Err(ConvertError {
197 code: "invalid_utf16",
198 message: format!(
199 "unpaired surrogate at byte {}",
200 mark_len + consumed_units * 2
201 ),
202 });
203 }
204 }
205 }
206 Ok(decoded)
207}
208
209impl Converter for PlainTextPassthrough {
210 fn id(&self) -> &'static str {
211 PASSTHROUGH_ID
212 }
213
214 fn version(&self) -> &'static str {
215 PASSTHROUGH_VERSION
216 }
217
218 fn convert(
219 &self,
220 source: &[u8],
221 detected_format: &str,
222 ) -> std::result::Result<Outcome, ConvertError> {
223 let source_len = source.len();
224 let mut warnings = Vec::new();
225 let raw = if source.starts_with(&[0xFF, 0xFE, 0x00, 0x00])
229 || source.starts_with(&[0x00, 0x00, 0xFE, 0xFF])
230 {
231 return Err(ConvertError {
232 code: "unsupported-encoding",
233 message: "utf-32 byte order mark".to_string(),
234 });
235 } else if let Some(rest) = source.strip_prefix(&[0xFF, 0xFE][..]) {
236 warnings.push("transcoded from utf-16le".to_string());
237 decode_utf16_pairs(rest, false, 2)?
238 } else if let Some(rest) = source.strip_prefix(&[0xFE, 0xFF][..]) {
239 warnings.push("transcoded from utf-16be".to_string());
240 decode_utf16_pairs(rest, true, 2)?
241 } else {
242 let raw = String::from_utf8(source.to_vec()).map_err(|e| ConvertError {
243 code: "invalid_utf8",
244 message: format!("invalid UTF-8 at byte {}", e.utf8_error().valid_up_to()),
245 })?;
246 match raw.strip_prefix('\u{feff}') {
247 Some(stripped) => {
248 warnings.push("stripped leading byte order mark".to_string());
249 stripped.to_string()
250 }
251 None => raw,
252 }
253 };
254 let text = normalize_text(&raw);
255 if source_len > 0 && text.is_empty() {
256 return Err(ConvertError {
257 code: "empty_output",
258 message: format!("source is {source_len} bytes but conversion produced no text"),
259 });
260 }
261 let segments = vec![Segment::span(0, text.len(), "document")];
262 Ok(Outcome {
263 converter_id: PASSTHROUGH_ID.to_string(),
264 converter_version: PASSTHROUGH_VERSION.to_string(),
265 detected_format: detected_format.to_string(),
266 text,
267 warnings,
268 segments,
269 })
270 }
271}
272
273#[derive(Debug, Deserialize)]
274#[serde(deny_unknown_fields)]
275struct RawRegistry {
276 version: String,
277 converters: Vec<RawEntry>,
278 #[serde(default)]
279 unsupported: Vec<RawUnsupported>,
280}
281
282#[derive(Debug, Deserialize)]
283#[serde(deny_unknown_fields)]
284struct RawEntry {
285 id: String,
286 version: String,
287 formats: Vec<String>,
288}
289
290#[derive(Debug, Deserialize)]
291#[serde(deny_unknown_fields)]
292struct RawUnsupported {
293 reason: String,
294 formats: Vec<String>,
295}
296
297pub struct Registry {
303 version: String,
304 converters: Vec<Box<dyn Converter>>,
305 by_format: HashMap<String, usize>,
306 unsupported_reasons: HashMap<String, String>,
307}
308
309impl Registry {
310 pub fn builtin() -> Result<Self> {
312 Self::parse(
313 include_str!("../../rules/converters.toml"),
314 "converters.toml",
315 )
316 }
317
318 pub fn parse(text: &str, name: &str) -> Result<Self> {
320 let raw: RawRegistry = toml::from_str(text).map_err(|e| Error::Rules {
321 name: name.to_string(),
322 message: e.to_string(),
323 })?;
324 let mut converters: Vec<Box<dyn Converter>> = Vec::new();
325 let mut by_format = HashMap::new();
326 for entry in &raw.converters {
327 let converter: Box<dyn Converter> = match entry.id.as_str() {
328 PASSTHROUGH_ID => Box::new(PlainTextPassthrough),
329 document::ANYDOC_ID => Box::new(AnydocDocument),
330 workbook::WORKBOOK_ID => Box::new(WorkbookIr),
331 subprocess::PDF_SUBPROCESS_ID => Box::new(PdfSubprocess),
332 other => {
333 return Err(Error::Rules {
334 name: name.to_string(),
335 message: format!("unknown converter id {other:?}"),
336 });
337 }
338 };
339 if entry.version != converter.version() {
340 return Err(Error::Rules {
341 name: name.to_string(),
342 message: format!(
343 "converter {:?} pins version {:?} but the implementation is {:?}",
344 entry.id,
345 entry.version,
346 converter.version()
347 ),
348 });
349 }
350 let index = converters.len();
351 converters.push(converter);
352 for format in &entry.formats {
353 if RESERVED_FORMAT_IDS.contains(&format.as_str()) {
354 return Err(Error::Rules {
355 name: name.to_string(),
356 message: format!("format id {format:?} is reserved"),
357 });
358 }
359 if by_format.insert(format.clone(), index).is_some() {
360 return Err(Error::Rules {
361 name: name.to_string(),
362 message: format!("format {format:?} claimed twice"),
363 });
364 }
365 }
366 }
367 let mut unsupported_reasons = HashMap::new();
368 for entry in &raw.unsupported {
369 for format in &entry.formats {
370 if RESERVED_FORMAT_IDS.contains(&format.as_str()) {
371 return Err(Error::Rules {
372 name: name.to_string(),
373 message: format!("format id {format:?} is reserved"),
374 });
375 }
376 if by_format.contains_key(format)
377 || unsupported_reasons
378 .insert(format.clone(), entry.reason.clone())
379 .is_some()
380 {
381 return Err(Error::Rules {
382 name: name.to_string(),
383 message: format!("format {format:?} claimed twice"),
384 });
385 }
386 }
387 }
388 Ok(Registry {
389 version: raw.version,
390 converters,
391 by_format,
392 unsupported_reasons,
393 })
394 }
395
396 pub fn version(&self) -> &str {
398 &self.version
399 }
400
401 pub fn converter_for(&self, format_id: &str) -> Option<&dyn Converter> {
403 self.by_format
404 .get(format_id)
405 .map(|index| self.converters[*index].as_ref())
406 }
407
408 pub fn unsupported_reason(&self, format_id: &str) -> Option<&str> {
416 if self.by_format.contains_key(format_id) {
417 return None;
418 }
419 Some(
420 self.unsupported_reasons
421 .get(format_id)
422 .map(String::as_str)
423 .unwrap_or(NO_CONVERTER_REASON),
424 )
425 }
426
427 #[cfg(test)]
431 pub(crate) fn for_tests(
432 version: &str,
433 entries: Vec<(Box<dyn Converter>, Vec<&str>)>,
434 ) -> Registry {
435 let mut converters = Vec::new();
436 let mut by_format = HashMap::new();
437 for (converter, formats) in entries {
438 let index = converters.len();
439 converters.push(converter);
440 for format in formats {
441 by_format.insert(format.to_string(), index);
442 }
443 }
444 Registry {
445 version: version.to_string(),
446 converters,
447 by_format,
448 unsupported_reasons: HashMap::new(),
449 }
450 }
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456
457 #[test]
458 fn normalize_converts_crlf_to_lf() {
459 assert_eq!(normalize_text("a\r\nb\r\n"), "a\nb\n");
460 }
461
462 #[test]
463 fn normalize_converts_lone_cr_to_lf() {
464 assert_eq!(normalize_text("a\rb\r"), "a\nb\n");
465 assert_eq!(normalize_text("a\r\rb"), "a\n\nb");
466 }
467
468 #[test]
469 fn normalize_composes_to_nfc() {
470 assert_eq!(normalize_text("cafe\u{301}"), "caf\u{e9}");
471 }
472
473 #[test]
474 fn normalize_never_applies_nfkc() {
475 assert_eq!(normalize_text("\u{fb01}le \u{2460}"), "\u{fb01}le \u{2460}");
476 }
477
478 #[test]
479 fn passthrough_rejects_invalid_utf8() {
480 let err = PlainTextPassthrough
481 .convert(b"hello \xff world", "text")
482 .unwrap_err();
483 assert_eq!(err.code, "invalid_utf8");
484 }
485
486 #[test]
487 fn passthrough_fails_on_suspiciously_empty_output() {
488 let err = PlainTextPassthrough
489 .convert("\u{feff}".as_bytes(), "text")
490 .unwrap_err();
491 assert_eq!(err.code, "empty_output");
492 }
493
494 #[test]
495 fn passthrough_accepts_an_empty_source() {
496 let outcome = PlainTextPassthrough.convert(b"", "text").unwrap();
497 assert_eq!(outcome.text, "");
498 }
499
500 fn utf16_bytes(text: &str, big_endian: bool) -> Vec<u8> {
501 let mut bytes = if big_endian {
502 vec![0xFE, 0xFF]
503 } else {
504 vec![0xFF, 0xFE]
505 };
506 for unit in text.encode_utf16() {
507 bytes.extend_from_slice(&if big_endian {
508 unit.to_be_bytes()
509 } else {
510 unit.to_le_bytes()
511 });
512 }
513 bytes
514 }
515
516 #[test]
517 fn passthrough_transcodes_utf16le_behind_a_byte_order_mark() {
518 let source = utf16_bytes("a,b\r\n1,caf\u{e9}\r\n", false);
519 let outcome = PlainTextPassthrough.convert(&source, "csv").unwrap();
520 assert_eq!(outcome.text, "a,b\n1,caf\u{e9}\n");
521 assert_eq!(
522 outcome.warnings,
523 vec!["transcoded from utf-16le".to_string()]
524 );
525 }
526
527 #[test]
528 fn passthrough_transcodes_utf16be_behind_a_byte_order_mark() {
529 let source = utf16_bytes("total \u{5317}\u{4eac}\n", true);
530 let outcome = PlainTextPassthrough.convert(&source, "text").unwrap();
531 assert_eq!(outcome.text, "total \u{5317}\u{4eac}\n");
532 assert_eq!(
533 outcome.warnings,
534 vec!["transcoded from utf-16be".to_string()]
535 );
536 }
537
538 #[test]
539 fn passthrough_refuses_utf32_byte_order_marks() {
540 for source in [
541 b"\xFF\xFE\x00\x00A\x00\x00\x00".as_slice(),
542 b"\x00\x00\xFE\xFF\x00\x00\x00A".as_slice(),
543 ] {
544 let err = PlainTextPassthrough.convert(source, "text").unwrap_err();
545 assert_eq!(err.code, "unsupported-encoding");
546 assert!(err.to_string().starts_with("unsupported-encoding: utf-32"));
547 }
548 }
549
550 #[test]
551 fn passthrough_fails_an_odd_utf16_tail_at_its_offset() {
552 let err = PlainTextPassthrough
553 .convert(b"\xFF\xFEA\x00B", "text")
554 .unwrap_err();
555 assert_eq!(err.code, "invalid_utf16");
556 assert_eq!(err.message, "odd trailing byte at byte 4");
557 }
558
559 #[test]
560 fn passthrough_fails_an_unpaired_surrogate_at_its_offset() {
561 let err = PlainTextPassthrough
562 .convert(b"\xFF\xFEA\x00\x00\xD8", "text")
563 .unwrap_err();
564 assert_eq!(err.code, "invalid_utf16");
565 assert_eq!(err.message, "unpaired surrogate at byte 4");
566 }
567
568 #[test]
569 fn passthrough_still_fails_bomless_utf16_as_invalid_utf8() {
570 let mut source = utf16_bytes("R\u{e9}sum\u{e9}\n", false);
571 source.drain(..2);
572 let err = PlainTextPassthrough.convert(&source, "text").unwrap_err();
573 assert_eq!(err.code, "invalid_utf8");
574 }
575
576 #[test]
577 fn builtin_registry_claims_the_text_family() {
578 let registry = Registry::builtin().unwrap();
579 assert_eq!(registry.version(), "4");
580 assert!(registry.converter_for("json").is_some());
581 assert!(registry.converter_for("yaml").is_some());
582 assert!(registry.converter_for("svg").is_some());
583 assert!(registry.converter_for("ipynb").is_some());
584 assert!(registry.converter_for("python").is_some());
585 assert!(registry.converter_for("pickle").is_none());
586 assert!(registry.converter_for("text").is_some());
587 assert!(registry.converter_for("markdown").is_some());
588 assert!(registry.converter_for("csv").is_some());
589 assert!(registry.converter_for("pdf").is_some());
590 assert_eq!(
591 registry.converter_for("pdf").map(|c| c.id()),
592 Some("pdf-subprocess")
593 );
594 assert!(registry.converter_for("docx").is_some());
595 assert!(registry.converter_for("xlsx").is_some());
596 assert!(registry.converter_for("html").is_none());
597 assert!(registry.converter_for("xlsb").is_none());
598 assert_eq!(
599 registry.unsupported_reason("xlsb"),
600 Some("hidden-visibility-unresolved")
601 );
602 assert_eq!(
603 registry.unsupported_reason("pickle"),
604 Some("pickle-deserialization-unsafe")
605 );
606 assert_eq!(registry.unsupported_reason("webp"), Some("engine-unpinned"));
607 assert_eq!(
608 registry.unsupported_reason("parquet"),
609 Some("converter-deferred")
610 );
611 assert_eq!(
612 registry.unsupported_reason("tar"),
613 Some("container-deferred")
614 );
615 assert_eq!(
616 registry.unsupported_reason("psd"),
617 Some("proprietary-binary")
618 );
619 assert_eq!(
621 registry.unsupported_reason("unknown"),
622 Some(NO_CONVERTER_REASON)
623 );
624 assert_eq!(registry.unsupported_reason("text"), None);
626 assert_eq!(registry.unsupported_reason("png"), Some("engine-unpinned"));
627 assert_eq!(registry.unsupported_reason("mp4"), Some("engine-unpinned"));
628 assert_eq!(
631 registry.unsupported_reason("html"),
632 Some(NO_CONVERTER_REASON)
633 );
634 }
635
636 #[test]
637 fn registry_rejects_reserved_format_ids() {
638 for (section, id) in [
639 ("converters", "symlink"),
640 ("converters", "unknown"),
641 ("unsupported", "symlink"),
642 ("unsupported", "unknown"),
643 ] {
644 let toml = if section == "converters" {
645 format!(
646 "version = \"1\"\n[[converters]]\nid = \"text-passthrough\"\nversion = \"1.1.0\"\nformats = [\"{id}\"]\n"
647 )
648 } else {
649 format!(
650 "version = \"1\"\nconverters = []\n[[unsupported]]\nreason = \"r\"\nformats = [\"{id}\"]\n"
651 )
652 };
653 let err = match Registry::parse(&toml, "converters.toml") {
654 Ok(_) => panic!("{section} {id}: reserved id was accepted"),
655 Err(e) => e,
656 };
657 assert!(
658 err.to_string().contains("reserved"),
659 "{section} {id}: {err}"
660 );
661 }
662 }
663
664 #[test]
665 fn registry_rejects_a_version_that_contradicts_the_implementation() {
666 let toml = r#"
667version = "1"
668
669[[converters]]
670id = "text-passthrough"
671version = "9.9.9"
672formats = ["text"]
673"#;
674 assert!(Registry::parse(toml, "converters.toml").is_err());
675 }
676}