1use serde::de::DeserializeOwned;
16
17use crate::segments::Segment;
18
19use super::{ConvertError, Converter, Outcome, normalize_text};
20
21pub const PDF_SUBPROCESS_ID: &str = "pdf-subprocess";
23pub const PDF_SUBPROCESS_VERSION: &str = "1.0.0";
25
26pub const PDF_RECOVERED_ID: &str = "pdf-text-recovered";
31pub const PDF_RECOVERED_VERSION: &str = PDF_SUBPROCESS_VERSION;
42
43#[cfg_attr(not(unix), allow(dead_code))]
48pub(crate) fn pdf_converter_identity(recovered: bool) -> (&'static str, &'static str) {
49 if recovered {
50 (PDF_RECOVERED_ID, PDF_RECOVERED_VERSION)
51 } else {
52 (PDF_SUBPROCESS_ID, PDF_SUBPROCESS_VERSION)
53 }
54}
55
56pub const RECORDS_SUBPROCESS_ID: &str = "records-worker";
58pub const RECORDS_SUBPROCESS_VERSION: &str = "1.0.0";
60
61pub const IMAGE_PIXEL_OCR_ID: &str = "image-pixel-ocr";
63pub const IMAGE_PIXEL_OCR_VERSION: &str = "1.0.0";
65
66pub const OCR_ADAPTER_ID: &str = "ocr-adapter";
68pub const OCR_ADAPTER_VERSION: &str = "1.0.0";
70
71pub const ASR_ADAPTER_ID: &str = "asr-adapter";
73pub const ASR_ADAPTER_VERSION: &str = "1.0.0";
75
76pub const VIDEO_ADAPTER_ID: &str = "video-adapter";
78pub const VIDEO_ADAPTER_VERSION: &str = "1.0.0";
80
81pub const OCR_CONFIDENCE_WARNING: f64 = 0.5;
83
84pub fn format_timecode(seconds: f64) -> Result<String, String> {
87 if !seconds.is_finite() || seconds < 0.0 {
88 return Err(format!("timestamp {seconds} is not a non-negative time"));
89 }
90 let whole = seconds as u64;
91 Ok(format!(
92 "{:02}:{:02}:{:02}",
93 whole / 3600,
94 (whole % 3600) / 60,
95 whole % 60
96 ))
97}
98
99fn single_line(text: &str) -> String {
100 text.replace(['\n', '\r'], " ").trim().to_string()
101}
102
103#[cfg(unix)]
104mod imp {
105 use std::sync::OnceLock;
106
107 use super::*;
108 use crate::convert::RecordsLimits;
109 use crate::manifest::ArtifactKind;
110 use crate::runner::protocol::bodies::{
111 AsrOk, AsrRequest, OcrInput, OcrOk, OcrRequest, PdfOk, PdfRequest, RecordsOk,
112 RecordsRequest, VideoOk, VideoRequest,
113 };
114 use crate::runner::{Runner, RunnerError};
115
116 fn static_code(code: &str) -> &'static str {
121 const KNOWN: &[&str] = &[
122 "pdf_no_text_layer",
123 "malformed",
124 "encrypted",
125 "resourceLimit",
126 "missingPart",
127 "unsupported",
128 "io",
129 "empty_output",
130 "invalid_utf8",
131 "converter_panic",
132 "unclaimed_format",
133 "resource_limit",
134 "record-limit-exceeded",
135 "records_read_error",
136 "not_sqlite",
137 "image-ocr-decode-failed",
141 "image-ocr-area-exceeded",
142 "image-ocr-runtime-missing",
143 "image-ocr-runtime-mismatch",
144 "image-ocr-no-accelerator",
145 "ocr-protocol-error",
146 "image-metadata-malformed",
150 "image-metadata-decompress-exceeded",
151 "image-metadata-xml-exceeded",
152 "image-metadata-limit-exceeded",
153 "image-metadata-unsupported",
154 ];
155 KNOWN
156 .iter()
157 .find(|known| **known == code)
158 .copied()
159 .unwrap_or("adapter_error")
160 }
161
162 fn runner_failure(error: RunnerError) -> ConvertError {
163 ConvertError {
164 code: error.code,
165 message: error.message,
166 }
167 }
168
169 fn call<Req: serde::Serialize, Ok: DeserializeOwned>(
171 runner: &Runner,
172 mode: &str,
173 request: &Req,
174 files: &[(&str, &[u8])],
175 ) -> Result<Ok, ConvertError> {
176 let payload = serde_json::to_value(request).map_err(|e| ConvertError {
177 code: "adapter_error",
178 message: format!("cannot encode the adapter request: {e}"),
179 })?;
180 let response = runner.run(mode, payload, files).map_err(runner_failure)?;
181 if let Some(error) = response.error {
182 return Err(ConvertError {
183 code: static_code(&error.code),
184 message: error.message,
185 });
186 }
187 let ok = response.ok.expect("validated response has a body");
188 serde_json::from_value(ok).map_err(|e| ConvertError {
189 code: "adapter_error",
190 message: format!("adapter success body did not parse: {e}"),
191 })
192 }
193
194 fn shared_runner() -> Result<&'static Runner, ConvertError> {
195 static RUNNER: OnceLock<Result<Runner, RunnerError>> = OnceLock::new();
196 match RUNNER.get_or_init(Runner::with_platform_defaults) {
197 Ok(runner) => Ok(runner),
198 Err(error) => Err(runner_failure(error.clone())),
199 }
200 }
201
202 pub struct PdfSubprocess;
208
209 impl Converter for PdfSubprocess {
210 fn id(&self) -> &'static str {
211 PDF_SUBPROCESS_ID
212 }
213
214 fn version(&self) -> &'static str {
215 PDF_SUBPROCESS_VERSION
216 }
217
218 fn convert(
219 &self,
220 source: &[u8],
221 detected_format: &str,
222 ) -> std::result::Result<Outcome, ConvertError> {
223 if !matches!(detected_format, "pdf" | "ai") {
229 return Err(ConvertError {
230 code: "unclaimed_format",
231 message: format!(
232 "the subprocess PDF adapter does not handle {detected_format}"
233 ),
234 });
235 }
236 let body: PdfOk = call(
237 shared_runner()?,
238 "pdf",
239 &PdfRequest {
240 input: "input.pdf".to_string(),
241 },
242 &[("input.pdf", source)],
243 )?;
244 let (converter_id, converter_version) = pdf_converter_identity(body.recovered);
245 Ok(Outcome {
246 artifact_kind: ArtifactKind::Text,
247 converter_id: converter_id.to_string(),
248 converter_version: converter_version.to_string(),
249 detected_format: detected_format.to_string(),
250 text: body.text,
251 warnings: body.warnings,
252 segments: body.segments,
253 })
254 }
255 }
256
257 pub struct RecordsSubprocess {
268 limits: RecordsLimits,
269 }
270
271 impl RecordsSubprocess {
272 pub fn new(limits: RecordsLimits) -> RecordsSubprocess {
274 RecordsSubprocess { limits }
275 }
276 }
277
278 impl Converter for RecordsSubprocess {
279 fn id(&self) -> &'static str {
280 RECORDS_SUBPROCESS_ID
281 }
282
283 fn version(&self) -> &'static str {
284 RECORDS_SUBPROCESS_VERSION
285 }
286
287 fn convert(
288 &self,
289 source: &[u8],
290 detected_format: &str,
291 ) -> std::result::Result<Outcome, ConvertError> {
292 if !matches!(detected_format, "parquet" | "avro" | "sqlite") {
293 return Err(ConvertError {
294 code: "unclaimed_format",
295 message: format!("the records worker does not handle {detected_format}"),
296 });
297 }
298 let input = format!("input.{detected_format}");
299 let body: RecordsOk = call(
300 shared_runner()?,
301 "records",
302 &RecordsRequest {
303 input: input.clone(),
304 format: detected_format.to_string(),
305 max_records: self.limits.max_records,
306 max_tables: self.limits.max_tables,
307 max_output_bytes: self.limits.max_output_bytes,
308 },
309 &[(&input, source)],
310 )?;
311 Ok(Outcome {
312 artifact_kind: ArtifactKind::Text,
313 converter_id: RECORDS_SUBPROCESS_ID.to_string(),
314 converter_version: RECORDS_SUBPROCESS_VERSION.to_string(),
315 detected_format: detected_format.to_string(),
316 text: body.text,
317 warnings: body.warnings,
318 segments: body.segments,
319 })
320 }
321 }
322
323 #[cfg(feature = "image-metadata")]
339 pub struct ImageMetadata {
340 limits: crate::convert::ImageMetadataLimits,
341 }
342
343 #[cfg(feature = "image-metadata")]
344 impl ImageMetadata {
345 pub fn new(limits: crate::convert::ImageMetadataLimits) -> ImageMetadata {
347 ImageMetadata { limits }
348 }
349 }
350
351 #[cfg(feature = "image-metadata")]
352 impl Converter for ImageMetadata {
353 fn id(&self) -> &'static str {
354 crate::convert::image_metadata::IMAGE_METADATA_ID
355 }
356
357 fn version(&self) -> &'static str {
358 crate::convert::image_metadata::IMAGE_METADATA_VERSION
359 }
360
361 fn convert(
362 &self,
363 source: &[u8],
364 detected_format: &str,
365 ) -> std::result::Result<Outcome, ConvertError> {
366 use crate::runner::protocol::bodies::{ImageMetadataOk, ImageMetadataRequest};
367 if !crate::convert::image_metadata::image_metadata_applies(detected_format) {
368 return Err(ConvertError {
369 code: "unclaimed_format",
370 message: format!("the image-metadata worker does not read {detected_format}"),
371 });
372 }
373 let input = format!("input.{detected_format}");
374 let body: ImageMetadataOk = call(
375 shared_runner()?,
376 "image-metadata",
377 &ImageMetadataRequest {
378 input: input.clone(),
379 format: detected_format.to_string(),
380 max_decompressed_bytes: self.limits.max_decompressed_bytes,
381 max_xml_depth: self.limits.max_xml_depth,
382 max_xml_events: self.limits.max_xml_events,
383 max_boxes: self.limits.max_boxes,
384 max_rows: self.limits.max_rows,
385 max_output_bytes: self.limits.max_output_bytes,
386 },
387 &[(&input, source)],
388 )?;
389 if body.rows.is_empty() {
392 return Err(ConvertError {
393 code: crate::convert::image_metadata::IMAGE_METADATA_NOT_APPLICABLE,
394 message: "the image carries no textual metadata".to_string(),
395 });
396 }
397 let (text, segments) = crate::convert::image_metadata::render_rows(&body.rows);
398 if text.is_empty() {
401 return Err(empty_output(source.len()));
402 }
403 Ok(Outcome {
404 artifact_kind: ArtifactKind::Text,
405 converter_id: crate::convert::image_metadata::IMAGE_METADATA_ID.to_string(),
406 converter_version: crate::convert::image_metadata::IMAGE_METADATA_VERSION
407 .to_string(),
408 detected_format: detected_format.to_string(),
409 text,
410 warnings: Vec::new(),
411 segments,
412 })
413 }
414 }
415
416 #[cfg(feature = "image-ocr")]
428 pub struct ImagePixelOcr {
429 mode: &'static str,
433 runner: Result<Runner, RunnerError>,
439 }
440
441 #[cfg(feature = "image-ocr")]
442 impl ImagePixelOcr {
443 pub fn new(limits: crate::convert::ImageOcrLimits) -> ImagePixelOcr {
448 ImagePixelOcr {
449 mode: "image-ocr",
450 runner: build_image_runner(image_runner_limits(&limits)),
451 }
452 }
453
454 #[cfg(feature = "test-adapters")]
461 pub fn new_fake(limits: crate::convert::ImageOcrLimits) -> ImagePixelOcr {
462 ImagePixelOcr {
463 mode: "image-ocr-fake",
464 runner: build_image_runner(image_runner_limits(&limits)),
465 }
466 }
467 }
468
469 #[cfg(feature = "image-ocr")]
471 fn image_runner_limits(limits: &crate::convert::ImageOcrLimits) -> crate::runner::Limits {
472 crate::runner::Limits {
473 wall_timeout: std::time::Duration::from_secs(limits.wall_timeout_secs),
474 max_response_bytes: limits.max_response_bytes,
475 max_stderr_bytes: limits.max_stderr_bytes,
476 address_space_bytes: limits.address_space_bytes,
477 cpu_seconds: limits.cpu_seconds,
478 file_size_bytes: limits.file_size_bytes,
479 max_processes: limits.max_processes,
480 }
481 }
482
483 #[cfg(feature = "image-ocr")]
487 fn build_image_runner(limits: crate::runner::Limits) -> Result<Runner, RunnerError> {
488 Ok(Runner::new(
489 crate::runner::jail::platform_backend()?,
490 crate::runner::locate_worker()?,
491 limits,
492 ))
493 }
494
495 #[cfg(feature = "image-ocr")]
496 impl Converter for ImagePixelOcr {
497 fn id(&self) -> &'static str {
498 IMAGE_PIXEL_OCR_ID
499 }
500
501 fn version(&self) -> &'static str {
502 IMAGE_PIXEL_OCR_VERSION
503 }
504
505 fn convert(
506 &self,
507 source: &[u8],
508 detected_format: &str,
509 ) -> std::result::Result<Outcome, ConvertError> {
510 if !matches!(detected_format, "png" | "jpeg" | "webp") {
511 return Err(ConvertError {
512 code: "unclaimed_format",
513 message: format!(
514 "the image-pixel-ocr converter does not handle {detected_format}"
515 ),
516 });
517 }
518 let runner = self
519 .runner
520 .as_ref()
521 .map_err(|e| runner_failure(e.clone()))?;
522 let input = format!("input.{detected_format}");
523 let body: OcrOk = call(
524 runner,
525 self.mode,
526 &OcrRequest {
527 input: input.clone(),
528 kind: OcrInput::Image,
529 },
530 &[(&input, source)],
531 )?;
532 let mut warnings = body.warnings;
536 let (text, low_confidence) = render_ocr_spans(&body.spans, source.len())?;
537 warnings.extend(low_confidence);
538 Ok(Outcome {
539 converter_id: IMAGE_PIXEL_OCR_ID.to_string(),
540 converter_version: IMAGE_PIXEL_OCR_VERSION.to_string(),
541 detected_format: detected_format.to_string(),
542 artifact_kind: ArtifactKind::Ocr,
543 segments: vec![Segment::span(0, text.len(), "document")],
544 text,
545 warnings,
546 })
547 }
548 }
549
550 pub struct OcrAdapter {
553 runner: Runner,
554 }
555
556 impl OcrAdapter {
557 pub fn new(runner: Runner) -> OcrAdapter {
559 OcrAdapter { runner }
560 }
561 }
562
563 impl Converter for OcrAdapter {
564 fn id(&self) -> &'static str {
565 OCR_ADAPTER_ID
566 }
567
568 fn version(&self) -> &'static str {
569 OCR_ADAPTER_VERSION
570 }
571
572 fn convert(
573 &self,
574 source: &[u8],
575 detected_format: &str,
576 ) -> std::result::Result<Outcome, ConvertError> {
577 if !matches!(detected_format, "png" | "jpeg" | "tiff") {
578 return Err(ConvertError {
579 code: "unclaimed_format",
580 message: format!("the OCR adapter does not handle {detected_format}"),
581 });
582 }
583 let input = format!("input.{detected_format}");
584 let body: OcrOk = call(
585 &self.runner,
586 "ocr",
587 &OcrRequest {
588 input: input.clone(),
589 kind: OcrInput::Image,
590 },
591 &[(&input, source)],
592 )?;
593 let mut warnings = body.warnings;
596 let mut lines = Vec::new();
597 for (index, span) in body.spans.iter().enumerate() {
598 if !(0.0..=1.0).contains(&span.confidence) {
599 return Err(ConvertError {
600 code: "adapter_error",
601 message: format!(
602 "span {index} confidence {} is outside 0 to 1",
603 span.confidence
604 ),
605 });
606 }
607 if span.confidence < OCR_CONFIDENCE_WARNING {
608 warnings.push(format!(
609 "ocr_low_confidence: span {index} at {:.2}",
610 span.confidence
611 ));
612 }
613 lines.push(single_line(&span.text));
614 }
615 let text = normalize_text(&finish_lines(lines));
616 if !source.is_empty() && text.is_empty() {
617 return Err(empty_output(source.len()));
618 }
619 Ok(Outcome {
620 artifact_kind: ArtifactKind::Ocr,
622 converter_id: OCR_ADAPTER_ID.to_string(),
623 converter_version: OCR_ADAPTER_VERSION.to_string(),
624 detected_format: detected_format.to_string(),
625 segments: vec![Segment::span(0, text.len(), "document")],
626 text,
627 warnings,
628 })
629 }
630 }
631
632 pub struct AsrAdapter {
634 runner: Runner,
635 }
636
637 impl AsrAdapter {
638 pub fn new(runner: Runner) -> AsrAdapter {
640 AsrAdapter { runner }
641 }
642 }
643
644 impl Converter for AsrAdapter {
645 fn id(&self) -> &'static str {
646 ASR_ADAPTER_ID
647 }
648
649 fn version(&self) -> &'static str {
650 ASR_ADAPTER_VERSION
651 }
652
653 fn convert(
654 &self,
655 source: &[u8],
656 detected_format: &str,
657 ) -> std::result::Result<Outcome, ConvertError> {
658 if !matches!(detected_format, "mp3" | "wav" | "m4a") {
659 return Err(ConvertError {
660 code: "unclaimed_format",
661 message: format!("the ASR adapter does not handle {detected_format}"),
662 });
663 }
664 let input = format!("input.{detected_format}");
665 let body: AsrOk = call(
666 &self.runner,
667 "asr",
668 &AsrRequest {
669 input: input.clone(),
670 language: None,
671 },
672 &[(&input, source)],
673 )?;
674 let text = normalize_text(&render_transcript(&body.segments).map_err(|detail| {
675 ConvertError {
676 code: "adapter_error",
677 message: detail,
678 }
679 })?);
680 if !source.is_empty() && text.is_empty() {
681 return Err(empty_output(source.len()));
682 }
683 Ok(Outcome {
684 artifact_kind: ArtifactKind::Transcript,
686 converter_id: ASR_ADAPTER_ID.to_string(),
687 converter_version: ASR_ADAPTER_VERSION.to_string(),
688 detected_format: detected_format.to_string(),
689 segments: vec![Segment::span(0, text.len(), "document")],
690 text,
691 warnings: Vec::new(),
692 })
693 }
694 }
695
696 pub struct VideoAdapter {
698 runner: Runner,
699 }
700
701 impl VideoAdapter {
702 pub fn new(runner: Runner) -> VideoAdapter {
704 VideoAdapter { runner }
705 }
706 }
707
708 impl Converter for VideoAdapter {
709 fn id(&self) -> &'static str {
710 VIDEO_ADAPTER_ID
711 }
712
713 fn version(&self) -> &'static str {
714 VIDEO_ADAPTER_VERSION
715 }
716
717 fn convert(
718 &self,
719 source: &[u8],
720 detected_format: &str,
721 ) -> std::result::Result<Outcome, ConvertError> {
722 if !matches!(detected_format, "mp4" | "mov") {
723 return Err(ConvertError {
724 code: "unclaimed_format",
725 message: format!("the video adapter does not handle {detected_format}"),
726 });
727 }
728 let input = format!("input.{detected_format}");
729 let body: VideoOk = call(
730 &self.runner,
731 "video",
732 &VideoRequest {
733 input: input.clone(),
734 sample_interval_seconds: 1.0,
735 max_frames: 10_000,
736 },
737 &[(&input, source)],
738 )?;
739 let text = normalize_text(&render_screen_states(&body.states).map_err(|detail| {
740 ConvertError {
741 code: "adapter_error",
742 message: detail,
743 }
744 })?);
745 if !source.is_empty() && text.is_empty() {
746 return Err(empty_output(source.len()));
747 }
748 Ok(Outcome {
749 artifact_kind: ArtifactKind::Text,
750 converter_id: VIDEO_ADAPTER_ID.to_string(),
751 converter_version: VIDEO_ADAPTER_VERSION.to_string(),
752 detected_format: detected_format.to_string(),
753 segments: vec![Segment::span(0, text.len(), "document")],
754 text,
755 warnings: Vec::new(),
756 })
757 }
758 }
759
760 fn empty_output(source_len: usize) -> ConvertError {
761 ConvertError {
762 code: "empty_output",
763 message: format!("source is {source_len} bytes but conversion produced no text"),
764 }
765 }
766}
767
768#[cfg(all(unix, feature = "image-metadata"))]
769pub use imp::ImageMetadata;
770#[cfg(all(unix, feature = "image-ocr"))]
771pub use imp::ImagePixelOcr;
772#[cfg(unix)]
773pub use imp::{AsrAdapter, OcrAdapter, PdfSubprocess, RecordsSubprocess, VideoAdapter};
774
775#[cfg(not(unix))]
776mod imp {
777 use super::*;
778 use crate::convert::RecordsLimits;
779
780 fn no_backend() -> ConvertError {
781 ConvertError {
782 code: "sandbox_unavailable",
783 message: "no jail backend exists for this platform, refusing to run adapters"
784 .to_string(),
785 }
786 }
787
788 pub struct PdfSubprocess;
791
792 impl Converter for PdfSubprocess {
793 fn id(&self) -> &'static str {
794 PDF_SUBPROCESS_ID
795 }
796
797 fn version(&self) -> &'static str {
798 PDF_SUBPROCESS_VERSION
799 }
800
801 fn convert(
802 &self,
803 _source: &[u8],
804 _detected_format: &str,
805 ) -> std::result::Result<Outcome, ConvertError> {
806 Err(no_backend())
807 }
808 }
809
810 pub struct RecordsSubprocess;
813
814 impl RecordsSubprocess {
815 pub fn new(_limits: RecordsLimits) -> RecordsSubprocess {
817 RecordsSubprocess
818 }
819 }
820
821 impl Converter for RecordsSubprocess {
822 fn id(&self) -> &'static str {
823 RECORDS_SUBPROCESS_ID
824 }
825
826 fn version(&self) -> &'static str {
827 RECORDS_SUBPROCESS_VERSION
828 }
829
830 fn convert(
831 &self,
832 _source: &[u8],
833 _detected_format: &str,
834 ) -> std::result::Result<Outcome, ConvertError> {
835 Err(no_backend())
836 }
837 }
838}
839
840#[cfg(not(unix))]
841pub use imp::{PdfSubprocess, RecordsSubprocess};
842
843pub fn render_transcript(segments: &[crate::runner_bodies::AsrSegment]) -> Result<String, String> {
847 let mut ordered: Vec<_> = segments.iter().collect();
848 ordered.sort_by(|a, b| {
849 a.start_seconds
850 .total_cmp(&b.start_seconds)
851 .then(a.end_seconds.total_cmp(&b.end_seconds))
852 .then(a.speaker.cmp(&b.speaker))
853 });
854 let mut lines = Vec::new();
855 for segment in ordered {
856 if segment.end_seconds < segment.start_seconds {
857 return Err(format!(
858 "segment ends at {} before it starts at {}",
859 segment.end_seconds, segment.start_seconds
860 ));
861 }
862 lines.push(format!(
863 "[{} -> {}] Speaker {}: {}",
864 format_timecode(segment.start_seconds)?,
865 format_timecode(segment.end_seconds)?,
866 segment.speaker,
867 single_line(&segment.text)
868 ));
869 }
870 Ok(finish_lines(lines))
871}
872
873pub fn render_screen_states(
877 states: &[crate::runner_bodies::ScreenState],
878) -> Result<String, String> {
879 let mut ordered: Vec<_> = states.iter().collect();
880 ordered.sort_by(|a, b| {
881 a.first_seen_seconds
882 .total_cmp(&b.first_seen_seconds)
883 .then(a.text.cmp(&b.text))
884 });
885 let mut lines = Vec::new();
886 let mut previous: Option<String> = None;
887 for state in ordered {
888 let text = single_line(&state.text);
889 if previous.as_deref() == Some(text.as_str()) {
890 continue;
891 }
892 lines.push(format!(
893 "[on-screen @ {}] {}",
894 format_timecode(state.first_seen_seconds)?,
895 text
896 ));
897 previous = Some(text);
898 }
899 Ok(finish_lines(lines))
900}
901
902fn finish_lines(lines: Vec<String>) -> String {
903 if lines.is_empty() {
904 String::new()
905 } else {
906 let mut text = lines.join("\n");
907 text.push('\n');
908 text
909 }
910}
911
912#[cfg(all(unix, feature = "image-ocr"))]
927pub(crate) fn render_ocr_spans(
928 spans: &[crate::runner::protocol::bodies::OcrSpan],
929 source_len: usize,
930) -> Result<(String, Vec<String>), ConvertError> {
931 let mut warnings = Vec::new();
932 let mut lines = Vec::new();
933 for (index, span) in spans.iter().enumerate() {
934 if !(0.0..=1.0).contains(&span.confidence) {
935 return Err(ConvertError {
936 code: "ocr-protocol-error",
937 message: format!(
938 "span {index} confidence {} is outside 0 to 1",
939 span.confidence
940 ),
941 });
942 }
943 if span.confidence < OCR_CONFIDENCE_WARNING {
944 warnings.push(format!(
945 "ocr_low_confidence: span {index} at {:.2}",
946 span.confidence
947 ));
948 }
949 lines.push(single_line(&span.text));
950 }
951 let text = normalize_text(&finish_lines(lines));
952 if source_len > 0 && !text.chars().any(crate::convert::is_meaningful) {
953 return Err(ConvertError {
954 code: "empty_output",
955 message: format!("source is {source_len} bytes but conversion produced no text"),
956 });
957 }
958 Ok((text, warnings))
959}
960
961#[cfg(test)]
962mod tests {
963 use super::*;
964 use crate::runner_bodies::{AsrSegment, ScreenState};
965
966 #[test]
967 fn recovered_pdf_text_is_recorded_under_the_recovery_id() {
968 assert_eq!(
972 pdf_converter_identity(false),
973 (PDF_SUBPROCESS_ID, PDF_SUBPROCESS_VERSION)
974 );
975 assert_eq!(
976 pdf_converter_identity(true),
977 (PDF_RECOVERED_ID, PDF_RECOVERED_VERSION)
978 );
979 assert_ne!(PDF_RECOVERED_ID, PDF_SUBPROCESS_ID);
980 assert_eq!(PDF_RECOVERED_VERSION, PDF_SUBPROCESS_VERSION);
981 }
982
983 #[test]
984 fn timecodes_render_as_hours_minutes_seconds() {
985 assert_eq!(format_timecode(0.0).unwrap(), "00:00:00");
986 assert_eq!(format_timecode(12.9).unwrap(), "00:00:12");
987 assert_eq!(format_timecode(182.0).unwrap(), "00:03:02");
988 assert_eq!(format_timecode(3661.0).unwrap(), "01:01:01");
989 assert!(format_timecode(-1.0).is_err());
990 assert!(format_timecode(f64::NAN).is_err());
991 }
992
993 #[test]
994 fn transcripts_render_one_ordered_line_per_segment() {
995 let segments = vec![
996 AsrSegment {
997 start_seconds: 19.0,
998 end_seconds: 24.0,
999 speaker: 2,
1000 text: "second\nline".to_string(),
1001 },
1002 AsrSegment {
1003 start_seconds: 12.0,
1004 end_seconds: 19.0,
1005 speaker: 1,
1006 text: "first remark".to_string(),
1007 },
1008 ];
1009 assert_eq!(
1010 render_transcript(&segments).unwrap(),
1011 "[00:00:12 -> 00:00:19] Speaker 1: first remark\n\
1012 [00:00:19 -> 00:00:24] Speaker 2: second line\n"
1013 );
1014 }
1015
1016 #[test]
1017 fn transcripts_reject_a_segment_that_ends_before_it_starts() {
1018 let segments = vec![AsrSegment {
1019 start_seconds: 5.0,
1020 end_seconds: 4.0,
1021 speaker: 1,
1022 text: "impossible".to_string(),
1023 }];
1024 assert!(render_transcript(&segments).is_err());
1025 }
1026
1027 #[test]
1028 fn screen_states_render_deduplicated_in_order() {
1029 let states = vec![
1030 ScreenState {
1031 first_seen_seconds: 182.0,
1032 text: "Quarterly revenue".to_string(),
1033 },
1034 ScreenState {
1035 first_seen_seconds: 10.0,
1036 text: "Agenda".to_string(),
1037 },
1038 ScreenState {
1039 first_seen_seconds: 200.0,
1040 text: "Quarterly revenue".to_string(),
1041 },
1042 ];
1043 assert_eq!(
1044 render_screen_states(&states).unwrap(),
1045 "[on-screen @ 00:00:10] Agenda\n[on-screen @ 00:03:02] Quarterly revenue\n"
1046 );
1047 }
1048}