1use serde::de::DeserializeOwned;
18
19use crate::segments::Segment;
20
21use super::{ConvertError, Converter, Outcome, normalize_text};
22
23pub const PDF_SUBPROCESS_ID: &str = "pdf-subprocess";
25pub const PDF_SUBPROCESS_VERSION: &str = "1.0.0";
27
28pub const PDF_RECOVERED_ID: &str = "pdf-text-recovered";
33pub const PDF_RECOVERED_VERSION: &str = PDF_SUBPROCESS_VERSION;
44
45#[cfg_attr(not(unix), allow(dead_code))]
50pub(crate) fn pdf_converter_identity(recovered: bool) -> (&'static str, &'static str) {
51 if recovered {
52 (PDF_RECOVERED_ID, PDF_RECOVERED_VERSION)
53 } else {
54 (PDF_SUBPROCESS_ID, PDF_SUBPROCESS_VERSION)
55 }
56}
57
58pub const RECORDS_SUBPROCESS_ID: &str = "records-worker";
60pub const RECORDS_SUBPROCESS_VERSION: &str = "1.0.0";
62
63pub const IMAGE_PIXEL_OCR_ID: &str = "image-pixel-ocr";
65pub const IMAGE_PIXEL_OCR_VERSION: &str = "1.1.0";
70
71pub const OCR_SEGMENT_SOURCE_DOCUMENT: &str = "document";
73
74pub const OCR_SEGMENT_SOURCE_OCR: &str = "ocr";
78
79pub const OCR_ADAPTER_ID: &str = "ocr-adapter";
81pub const OCR_ADAPTER_VERSION: &str = "1.0.0";
83
84pub const ASR_ADAPTER_ID: &str = "asr-adapter";
86pub const ASR_ADAPTER_VERSION: &str = "2.0.0";
91
92pub const ASR_DECODE_POLICY: &str = "-l en -t 1 -p 1 -bo 1 -bs 1 -nf -tp 0 -tpi 0 -nfa -mc 0";
99
100pub const ASR_ROLE_CLI: &str = "asr-cli";
103pub const ASR_ROLE_WEIGHTS: &str = "asr-weights";
105pub const ASR_ROLE_DECODE_POLICY: &str = "asr-decode-policy";
108pub const ASR_ROLE_PROBE: &str = "asr-probe";
111
112pub const ASR_FILE_ROLE_LABELS: [&str; 3] = [ASR_ROLE_CLI, ASR_ROLE_WEIGHTS, ASR_ROLE_PROBE];
115
116pub const VIDEO_ADAPTER_ID: &str = "video-adapter";
118pub const VIDEO_ADAPTER_VERSION: &str = "1.0.0";
120
121pub const OCR_CONFIDENCE_WARNING: f64 = 0.5;
123
124pub fn format_timecode(seconds: f64) -> Result<String, String> {
127 if !seconds.is_finite() || seconds < 0.0 {
128 return Err(format!("timestamp {seconds} is not a non-negative time"));
129 }
130 let whole = seconds as u64;
131 Ok(format!(
132 "{:02}:{:02}:{:02}",
133 whole / 3600,
134 (whole % 3600) / 60,
135 whole % 60
136 ))
137}
138
139fn single_line(text: &str) -> String {
140 text.replace(['\n', '\r'], " ").trim().to_string()
141}
142
143#[cfg(unix)]
144mod imp {
145 use std::sync::OnceLock;
146
147 use super::*;
148 use crate::convert::RecordsLimits;
149 use crate::manifest::ArtifactKind;
150 use crate::runner::protocol::bodies::{
151 OcrInput, OcrOk, OcrRequest, PdfOk, PdfRequest, RecordsOk, RecordsRequest, VideoOk,
152 VideoRequest,
153 };
154 use crate::runner::{Runner, RunnerError};
155
156 fn static_code(code: &str) -> &'static str {
161 const KNOWN: &[&str] = &[
162 "pdf_no_text_layer",
163 "malformed",
164 "encrypted",
165 "resourceLimit",
166 "missingPart",
167 "unsupported",
168 "io",
169 "empty_output",
170 "invalid_utf8",
171 "converter_panic",
172 "unclaimed_format",
173 "resource_limit",
174 "record-limit-exceeded",
175 "records_read_error",
176 "not_sqlite",
177 "image-ocr-decode-failed",
181 "image-ocr-area-exceeded",
182 "image-ocr-runtime-missing",
183 "image-ocr-runtime-mismatch",
184 "image-ocr-no-accelerator",
185 "ocr-protocol-error",
186 "image-metadata-malformed",
190 "image-metadata-decompress-exceeded",
191 "image-metadata-xml-exceeded",
192 "image-metadata-limit-exceeded",
193 "image-metadata-unsupported",
194 "rasterizer-missing",
199 "rasterizer-hash-drift",
200 "rasterizer-version-drift",
201 "raster-exec-failed",
202 "raster-geometry-mismatch",
203 "encoder-area-cap",
204 "asr-decode-failed",
208 "asr-codec-unsupported",
209 "asr-duration-exceeded",
210 "asr-decoded-too-large",
211 "asr-no-speech",
212 "asr-runtime-missing",
213 "asr-runtime-mismatch",
214 "asr-backend-unavailable",
215 "asr-protocol-error",
216 ];
217 KNOWN
218 .iter()
219 .find(|known| **known == code)
220 .copied()
221 .unwrap_or("adapter_error")
222 }
223
224 fn runner_failure(error: RunnerError) -> ConvertError {
225 ConvertError {
226 code: error.code,
227 message: error.message(),
228 }
229 }
230
231 fn call<Req: serde::Serialize, Ok: DeserializeOwned>(
233 runner: &Runner,
234 mode: &str,
235 request: &Req,
236 files: &[(&str, &[u8])],
237 ) -> Result<Ok, ConvertError> {
238 let payload = serde_json::to_value(request).map_err(|e| ConvertError {
239 code: "adapter_error",
240 message: format!("cannot encode the adapter request: {e}"),
241 })?;
242 let response = runner.run(mode, payload, files).map_err(runner_failure)?;
243 if let Some(error) = response.error {
244 return Err(ConvertError {
245 code: static_code(&error.code),
246 message: error.message,
247 });
248 }
249 let ok = response.ok.expect("validated response has a body");
250 serde_json::from_value(ok).map_err(|e| ConvertError {
251 code: "adapter_error",
252 message: format!("adapter success body did not parse: {e}"),
253 })
254 }
255
256 fn shared_runner() -> Result<&'static Runner, ConvertError> {
257 static RUNNER: OnceLock<Result<Runner, RunnerError>> = OnceLock::new();
258 match RUNNER.get_or_init(Runner::with_platform_defaults) {
259 Ok(runner) => Ok(runner),
260 Err(error) => Err(runner_failure(error.clone())),
261 }
262 }
263
264 pub struct PdfSubprocess;
270
271 impl Converter for PdfSubprocess {
272 fn id(&self) -> &'static str {
273 PDF_SUBPROCESS_ID
274 }
275
276 fn version(&self) -> &'static str {
277 PDF_SUBPROCESS_VERSION
278 }
279
280 fn convert(
281 &self,
282 source: &[u8],
283 detected_format: &str,
284 ) -> std::result::Result<Outcome, ConvertError> {
285 if !matches!(detected_format, "pdf" | "ai") {
291 return Err(ConvertError {
292 code: "unclaimed_format",
293 message: format!(
294 "the subprocess PDF adapter does not handle {detected_format}"
295 ),
296 });
297 }
298 let body: PdfOk = call(
299 shared_runner()?,
300 "pdf",
301 &PdfRequest {
302 input: "input.pdf".to_string(),
303 },
304 &[("input.pdf", source)],
305 )?;
306 let (converter_id, converter_version) = pdf_converter_identity(body.recovered);
307 Ok(Outcome {
308 artifact_kind: ArtifactKind::Text,
309 converter_id: converter_id.to_string(),
310 converter_version: converter_version.to_string(),
311 detected_format: detected_format.to_string(),
312 text: body.text,
313 warnings: body.warnings,
314 segments: body.segments,
315 media: None,
316 })
317 }
318 }
319
320 pub struct RecordsSubprocess {
331 limits: RecordsLimits,
332 }
333
334 impl RecordsSubprocess {
335 pub fn new(limits: RecordsLimits) -> RecordsSubprocess {
337 RecordsSubprocess { limits }
338 }
339 }
340
341 impl Converter for RecordsSubprocess {
342 fn id(&self) -> &'static str {
343 RECORDS_SUBPROCESS_ID
344 }
345
346 fn version(&self) -> &'static str {
347 RECORDS_SUBPROCESS_VERSION
348 }
349
350 fn convert(
351 &self,
352 source: &[u8],
353 detected_format: &str,
354 ) -> std::result::Result<Outcome, ConvertError> {
355 if !matches!(detected_format, "parquet" | "avro" | "sqlite") {
356 return Err(ConvertError {
357 code: "unclaimed_format",
358 message: format!("the records worker does not handle {detected_format}"),
359 });
360 }
361 let input = format!("input.{detected_format}");
362 let body: RecordsOk = call(
363 shared_runner()?,
364 "records",
365 &RecordsRequest {
366 input: input.clone(),
367 format: detected_format.to_string(),
368 max_records: self.limits.max_records,
369 max_tables: self.limits.max_tables,
370 max_output_bytes: self.limits.max_output_bytes,
371 },
372 &[(&input, source)],
373 )?;
374 Ok(Outcome {
375 artifact_kind: ArtifactKind::Text,
376 converter_id: RECORDS_SUBPROCESS_ID.to_string(),
377 converter_version: RECORDS_SUBPROCESS_VERSION.to_string(),
378 detected_format: detected_format.to_string(),
379 text: body.text,
380 warnings: body.warnings,
381 segments: body.segments,
382 media: None,
383 })
384 }
385 }
386
387 #[cfg(feature = "image-metadata")]
403 pub struct ImageMetadata {
404 limits: crate::convert::ImageMetadataLimits,
405 }
406
407 #[cfg(feature = "image-metadata")]
408 impl ImageMetadata {
409 pub fn new(limits: crate::convert::ImageMetadataLimits) -> ImageMetadata {
411 ImageMetadata { limits }
412 }
413 }
414
415 #[cfg(feature = "image-metadata")]
416 impl Converter for ImageMetadata {
417 fn id(&self) -> &'static str {
418 crate::convert::image_metadata::IMAGE_METADATA_ID
419 }
420
421 fn version(&self) -> &'static str {
422 crate::convert::image_metadata::IMAGE_METADATA_VERSION
423 }
424
425 fn convert(
426 &self,
427 source: &[u8],
428 detected_format: &str,
429 ) -> std::result::Result<Outcome, ConvertError> {
430 use crate::runner::protocol::bodies::{ImageMetadataOk, ImageMetadataRequest};
431 if !crate::convert::image_metadata::image_metadata_applies(detected_format) {
432 return Err(ConvertError {
433 code: "unclaimed_format",
434 message: format!("the image-metadata worker does not read {detected_format}"),
435 });
436 }
437 let input = format!("input.{detected_format}");
438 let body: ImageMetadataOk = call(
439 shared_runner()?,
440 "image-metadata",
441 &ImageMetadataRequest {
442 input: input.clone(),
443 format: detected_format.to_string(),
444 max_decompressed_bytes: self.limits.max_decompressed_bytes,
445 max_xml_depth: self.limits.max_xml_depth,
446 max_xml_events: self.limits.max_xml_events,
447 max_boxes: self.limits.max_boxes,
448 max_rows: self.limits.max_rows,
449 max_output_bytes: self.limits.max_output_bytes,
450 },
451 &[(&input, source)],
452 )?;
453 if body.rows.is_empty() {
456 return Err(ConvertError {
457 code: crate::convert::image_metadata::IMAGE_METADATA_NOT_APPLICABLE,
458 message: "the image carries no textual metadata".to_string(),
459 });
460 }
461 let (text, segments) = crate::convert::image_metadata::render_rows(&body.rows);
462 if text.is_empty() {
465 return Err(empty_output(source.len()));
466 }
467 Ok(Outcome {
468 artifact_kind: ArtifactKind::Text,
469 converter_id: crate::convert::image_metadata::IMAGE_METADATA_ID.to_string(),
470 converter_version: crate::convert::image_metadata::IMAGE_METADATA_VERSION
471 .to_string(),
472 detected_format: detected_format.to_string(),
473 text,
474 warnings: Vec::new(),
475 segments,
476 media: None,
477 })
478 }
479 }
480
481 #[cfg(feature = "image-ocr")]
493 pub struct ImagePixelOcr {
494 mode: &'static str,
498 #[cfg(feature = "svg-provider")]
503 svg: Option<SvgProviderLeg>,
504 inventory: Vec<crate::runner::protocol::bodies::InventoryEntry>,
508 runner: Result<Runner, RunnerError>,
514 }
515
516 #[cfg(all(feature = "image-ocr", feature = "svg-provider"))]
519 struct SvgProviderLeg {
520 roles: Vec<crate::runner::protocol::bodies::SvgRoleEntry>,
521 runner: Result<Runner, RunnerError>,
522 #[cfg(feature = "test-adapters")]
527 replace_after_preflight: Option<std::path::PathBuf>,
528 }
529
530 #[cfg(all(feature = "image-ocr", feature = "svg-provider"))]
535 const PROVIDER_MAX_RESPONSE_BYTES: u32 = 64 * 1024 * 1024;
536
537 #[cfg(feature = "image-ocr")]
538 impl ImagePixelOcr {
539 pub fn new(
545 limits: crate::convert::ImageOcrLimits,
546 inventory: &crate::convert::RuntimeInventory,
547 ) -> ImagePixelOcr {
548 let entries: Vec<crate::runner::protocol::bodies::InventoryEntry> =
554 crate::convert::IMAGE_OCR_ROLE_LABELS
555 .iter()
556 .filter_map(|role| {
557 let path = inventory.path(role)?.to_str()?.to_string();
558 let expected = limits.inventory.get(*role)?.clone();
559 Some(crate::runner::protocol::bodies::InventoryEntry {
560 role: (*role).to_string(),
561 path,
562 expected_blake3: expected,
563 })
564 })
565 .collect();
566 let mut exec_grants = Vec::new();
567 let mut read_grants = Vec::new();
568 for entry in &entries {
569 let path = std::path::PathBuf::from(&entry.path);
570 if entry.role == "engine-cli" {
571 exec_grants.push(path);
572 } else {
573 read_grants.push(path);
574 }
575 }
576 ImagePixelOcr {
577 mode: "image-ocr",
578 #[cfg(feature = "svg-provider")]
579 svg: None,
580 inventory: entries,
581 runner: build_image_runner(image_runner_limits(&limits), exec_grants, read_grants),
585 }
586 }
587
588 #[cfg(feature = "svg-provider")]
597 pub fn with_svg_provider(
598 limits: crate::convert::ImageOcrLimits,
599 inventory: &crate::convert::RuntimeInventory,
600 provider: &crate::convert::provider::SvgProvider,
601 ) -> ImagePixelOcr {
602 use crate::runner::protocol::bodies::SvgRoleEntry;
603 let entry = |label: &str, role: &crate::convert::provider::ProviderRole| {
609 let pin = limits.svg_provider.get(label);
610 SvgRoleEntry {
611 role: label.to_string(),
612 path: role.path.display().to_string(),
613 expected_blake3: pin.map(|pin| pin.blake3.clone()).unwrap_or_default(),
614 version: pin.map(|pin| pin.version.clone()).unwrap_or_default(),
615 closure_roots: role
616 .closure_roots
617 .iter()
618 .map(|path| path.display().to_string())
619 .collect(),
620 closure_blake3: pin.and_then(|pin| pin.closure_blake3.clone()),
621 jail_temp_env: role.jail_temp_env.clone(),
622 }
623 };
624 let roles = vec![
625 entry(
626 crate::convert::provider::PROVIDER_ROLE_RASTER,
627 &provider.raster,
628 ),
629 entry(
630 crate::convert::provider::PROVIDER_ROLE_ENCODER,
631 &provider.encoder,
632 ),
633 ];
634 let exec_grants = vec![provider.raster.path.clone(), provider.encoder.path.clone()];
639 let mut closures = Vec::new();
640 for role in [&provider.raster, &provider.encoder] {
641 closures.extend(role.closure_roots.iter().cloned());
642 }
643 let mut provider_limits = image_runner_limits(&limits);
644 provider_limits.max_response_bytes = PROVIDER_MAX_RESPONSE_BYTES;
645 let runner = crate::runner::jail::ProviderJail::new(
649 closures,
650 provider.raster.jail_service_prefix.clone(),
651 provider.raster.jail_temp_env.clone(),
652 )
653 .and_then(|jail| {
654 Ok(Runner::with_provider_jail(
655 crate::runner::jail::platform_backend()?,
656 crate::runner::locate_worker()?,
657 provider_limits,
658 exec_grants,
659 Vec::new(),
660 jail,
661 ))
662 });
663 let mut converter = ImagePixelOcr::new(limits, inventory);
664 converter.svg = Some(SvgProviderLeg {
665 roles,
666 runner,
667 #[cfg(feature = "test-adapters")]
668 replace_after_preflight: None,
669 });
670 converter
671 }
672
673 #[cfg(all(feature = "svg-provider", feature = "test-adapters"))]
679 pub fn new_fake_with_svg_provider(
680 limits: crate::convert::ImageOcrLimits,
681 provider: &crate::convert::provider::SvgProvider,
682 ) -> ImagePixelOcr {
683 let wired = ImagePixelOcr::with_svg_provider(
684 limits.clone(),
685 &crate::convert::RuntimeInventory::empty(),
686 provider,
687 );
688 let mut converter = ImagePixelOcr::new_fake(limits);
689 converter.svg = wired.svg;
690 converter
691 }
692
693 #[cfg(all(feature = "svg-provider", feature = "test-adapters"))]
698 pub fn new_fake_with_svg_provider_spawn_recheck(
699 limits: crate::convert::ImageOcrLimits,
700 provider: &crate::convert::provider::SvgProvider,
701 replace_after_preflight: std::path::PathBuf,
702 ) -> ImagePixelOcr {
703 let mut converter = ImagePixelOcr::new_fake_with_svg_provider(limits, provider);
704 converter
705 .svg
706 .as_mut()
707 .expect("the provider leg is wired")
708 .replace_after_preflight = Some(replace_after_preflight);
709 converter
710 }
711
712 #[cfg(feature = "svg-provider")]
717 fn rasterize(&self, source: &[u8]) -> std::result::Result<Vec<u8>, ConvertError> {
718 use crate::runner::protocol::bodies::{SvgRasterOk, SvgRasterRequest};
719 let leg = self.svg.as_ref().ok_or_else(|| ConvertError {
720 code: "unclaimed_format",
721 message: "no svg provider is configured for this converter".to_string(),
722 })?;
723 for entry in &leg.roles {
730 let present = std::fs::symlink_metadata(&entry.path)
731 .is_ok_and(|metadata| metadata.file_type().is_file());
732 if !present {
733 return Err(ConvertError {
734 code: "rasterizer-missing",
735 message: format!("provider component {} is not present", entry.role),
736 });
737 }
738 }
739 #[cfg(feature = "test-adapters")]
740 if let Some(path) = &leg.replace_after_preflight {
741 std::fs::remove_file(path).map_err(|e| ConvertError {
742 code: "adapter_spawn_error",
743 message: format!(
744 "the provider spawn-check test seam could not remove its executable: {:?}",
745 e.kind()
746 ),
747 })?;
748 std::fs::create_dir_all(path).map_err(|e| ConvertError {
749 code: "adapter_spawn_error",
750 message: format!(
751 "the provider spawn-check test seam could not place its directory: {:?}",
752 e.kind()
753 ),
754 })?;
755 }
756 let runner = leg.runner.as_ref().map_err(|e| runner_failure(e.clone()))?;
757 let body: SvgRasterOk = call(
758 runner,
759 "svg-raster",
760 &SvgRasterRequest {
761 input: crate::convert::svg_raster::JAIL_SOURCE.to_string(),
762 roles: leg.roles.clone(),
763 },
764 &[(crate::convert::svg_raster::JAIL_SOURCE, source)],
765 )?;
766 crate::convert::svg_raster::hex_decode(&body.raster_png_hex).ok_or_else(|| {
767 ConvertError {
768 code: "raster-exec-failed",
769 message: "the provider returned a raster that does not decode".to_string(),
770 }
771 })
772 }
773
774 #[cfg(feature = "test-adapters")]
782 pub fn new_fake(limits: crate::convert::ImageOcrLimits) -> ImagePixelOcr {
783 ImagePixelOcr {
784 mode: "image-ocr-fake",
785 #[cfg(feature = "svg-provider")]
786 svg: None,
787 inventory: Vec::new(),
788 runner: build_image_runner(image_runner_limits(&limits), Vec::new(), Vec::new()),
789 }
790 }
791 }
792
793 #[cfg(feature = "image-ocr")]
795 fn image_runner_limits(limits: &crate::convert::ImageOcrLimits) -> crate::runner::Limits {
796 crate::runner::Limits {
797 wall_timeout: std::time::Duration::from_secs(limits.wall_timeout_secs),
798 max_response_bytes: limits.max_response_bytes,
799 max_stderr_bytes: limits.max_stderr_bytes,
800 address_space_bytes: Some(limits.address_space_bytes),
801 cpu_seconds: limits.cpu_seconds,
802 file_size_bytes: limits.file_size_bytes,
803 max_processes: limits.max_processes,
804 max_resident_bytes: None,
805 }
806 }
807
808 #[cfg(feature = "image-ocr")]
812 fn build_image_runner(
813 limits: crate::runner::Limits,
814 exec_grants: Vec<std::path::PathBuf>,
815 read_grants: Vec<std::path::PathBuf>,
816 ) -> Result<Runner, RunnerError> {
817 Ok(Runner::with_grants(
818 crate::runner::jail::platform_backend()?,
819 crate::runner::locate_worker()?,
820 limits,
821 exec_grants,
822 read_grants,
823 crate::runner::jail::RuntimeProfile::Plain,
824 ))
825 }
826
827 #[cfg(feature = "image-ocr")]
828 impl Converter for ImagePixelOcr {
829 fn id(&self) -> &'static str {
830 IMAGE_PIXEL_OCR_ID
831 }
832
833 fn version(&self) -> &'static str {
834 IMAGE_PIXEL_OCR_VERSION
835 }
836
837 fn convert(
838 &self,
839 source: &[u8],
840 detected_format: &str,
841 ) -> std::result::Result<Outcome, ConvertError> {
842 #[cfg(feature = "svg-provider")]
847 let is_svg = detected_format == "svg" && self.svg.is_some();
848 #[cfg(not(feature = "svg-provider"))]
849 let is_svg = false;
850 if !is_svg && !matches!(detected_format, "png" | "jpeg" | "webp") {
851 return Err(ConvertError {
852 code: "unclaimed_format",
853 message: format!(
854 "the image-pixel-ocr converter does not handle {detected_format}"
855 ),
856 });
857 }
858 #[cfg(feature = "svg-provider")]
863 let rendered = if is_svg {
864 Some(self.rasterize(source)?)
865 } else {
866 None
867 };
868 #[cfg(feature = "svg-provider")]
869 let (input, staged): (String, &[u8]) = match &rendered {
870 Some(raster) => ("input.png".to_string(), raster.as_slice()),
871 None => (format!("input.{detected_format}"), source),
872 };
873 #[cfg(not(feature = "svg-provider"))]
874 let (input, staged): (String, &[u8]) = (format!("input.{detected_format}"), source);
875 let runner = self
876 .runner
877 .as_ref()
878 .map_err(|e| runner_failure(e.clone()))?;
879 let body: OcrOk = call(
880 runner,
881 self.mode,
882 &OcrRequest {
883 input: input.clone(),
884 kind: OcrInput::Image,
885 inventory: self.inventory.clone(),
886 },
887 &[(&input, staged)],
888 )?;
889 let mut warnings = body.warnings;
893 let (text, low_confidence) = render_ocr_spans(&body.spans, source.len())?;
894 warnings.extend(low_confidence);
895 let segment_source = if is_svg {
900 OCR_SEGMENT_SOURCE_OCR
901 } else {
902 OCR_SEGMENT_SOURCE_DOCUMENT
903 };
904 Ok(Outcome {
905 converter_id: IMAGE_PIXEL_OCR_ID.to_string(),
906 converter_version: IMAGE_PIXEL_OCR_VERSION.to_string(),
907 detected_format: detected_format.to_string(),
908 artifact_kind: ArtifactKind::Ocr,
909 segments: vec![Segment::span(0, text.len(), segment_source)],
910 text,
911 warnings,
912 media: None,
913 })
914 }
915 }
916
917 pub struct OcrAdapter {
920 runner: Runner,
921 }
922
923 impl OcrAdapter {
924 pub fn new(runner: Runner) -> OcrAdapter {
926 OcrAdapter { runner }
927 }
928 }
929
930 impl Converter for OcrAdapter {
931 fn id(&self) -> &'static str {
932 OCR_ADAPTER_ID
933 }
934
935 fn version(&self) -> &'static str {
936 OCR_ADAPTER_VERSION
937 }
938
939 fn convert(
940 &self,
941 source: &[u8],
942 detected_format: &str,
943 ) -> std::result::Result<Outcome, ConvertError> {
944 if !matches!(detected_format, "png" | "jpeg" | "tiff") {
945 return Err(ConvertError {
946 code: "unclaimed_format",
947 message: format!("the OCR adapter does not handle {detected_format}"),
948 });
949 }
950 let input = format!("input.{detected_format}");
951 let body: OcrOk = call(
952 &self.runner,
953 "ocr",
954 &OcrRequest {
955 input: input.clone(),
956 kind: OcrInput::Image,
957 inventory: Vec::new(),
958 },
959 &[(&input, source)],
960 )?;
961 let mut warnings = body.warnings;
964 let mut lines = Vec::new();
965 for (index, span) in body.spans.iter().enumerate() {
966 if !(0.0..=1.0).contains(&span.confidence) {
967 return Err(ConvertError {
968 code: "adapter_error",
969 message: format!(
970 "span {index} confidence {} is outside 0 to 1",
971 span.confidence
972 ),
973 });
974 }
975 if span.confidence < OCR_CONFIDENCE_WARNING {
976 warnings.push(format!(
977 "ocr_low_confidence: span {index} at {:.2}",
978 span.confidence
979 ));
980 }
981 lines.push(single_line(&span.text));
982 }
983 let text = normalize_text(&finish_lines(lines));
984 if !source.is_empty() && text.is_empty() {
985 return Err(empty_output(source.len()));
986 }
987 Ok(Outcome {
988 artifact_kind: ArtifactKind::Ocr,
990 converter_id: OCR_ADAPTER_ID.to_string(),
991 converter_version: OCR_ADAPTER_VERSION.to_string(),
992 detected_format: detected_format.to_string(),
993 segments: vec![Segment::span(0, text.len(), "document")],
994 text,
995 warnings,
996 media: None,
997 })
998 }
999 }
1000
1001 #[cfg(feature = "audio-asr")]
1017 pub struct AsrAdapter {
1018 mode: &'static str,
1022 profile: crate::convert::AsrProfile,
1025 inventory: Vec<(String, std::path::PathBuf)>,
1027 runner: Result<Runner, RunnerError>,
1030 }
1031
1032 #[cfg(feature = "audio-asr")]
1033 impl AsrAdapter {
1034 pub fn new(
1040 profile: crate::convert::AsrProfile,
1041 inventory: &crate::convert::RuntimeInventory,
1042 ) -> AsrAdapter {
1043 let files = pinned_asr_runtime_files(&profile, inventory);
1044 let (exec_grants, read_grants) = asr_grant_lists(&files);
1045 AsrAdapter {
1050 mode: "asr",
1051 runner: build_asr_runner(
1052 &profile,
1053 exec_grants,
1054 read_grants,
1055 crate::runner::jail::RuntimeProfile::Accelerator,
1056 ),
1057 profile,
1058 inventory: files,
1059 }
1060 }
1061
1062 #[cfg(feature = "test-adapters")]
1070 pub fn new_fake(profile: crate::convert::AsrProfile) -> AsrAdapter {
1071 AsrAdapter {
1072 mode: "asr-fake",
1073 runner: build_asr_runner(
1074 &profile,
1075 Vec::new(),
1076 Vec::new(),
1077 crate::runner::jail::RuntimeProfile::Plain,
1078 ),
1079 profile,
1080 inventory: Vec::new(),
1081 }
1082 }
1083 }
1084
1085 #[cfg(feature = "audio-asr")]
1093 fn pinned_asr_runtime_files(
1094 profile: &crate::convert::AsrProfile,
1095 inventory: &crate::convert::RuntimeInventory,
1096 ) -> Vec<(String, std::path::PathBuf)> {
1097 let mut files = Vec::new();
1098 for role in ASR_FILE_ROLE_LABELS {
1099 if !profile.inventory.contains_key(role) {
1100 continue;
1101 }
1102 if let Some(path) = inventory.path(role) {
1103 files.push((role.to_string(), path.to_path_buf()));
1104 }
1105 }
1106 files
1107 }
1108
1109 #[cfg(feature = "audio-asr")]
1112 fn asr_grant_lists(
1113 files: &[(String, std::path::PathBuf)],
1114 ) -> (Vec<std::path::PathBuf>, Vec<std::path::PathBuf>) {
1115 let mut exec_grants = Vec::new();
1116 let mut read_grants = Vec::new();
1117 for (role, path) in files {
1118 if role == ASR_ROLE_WEIGHTS {
1119 read_grants.push(path.clone());
1120 } else {
1121 exec_grants.push(path.clone());
1122 }
1123 }
1124 (exec_grants, read_grants)
1125 }
1126
1127 #[cfg(feature = "audio-asr")]
1132 fn asr_runner_limits(profile: &crate::convert::AsrProfile) -> crate::runner::Limits {
1133 crate::runner::Limits {
1134 wall_timeout: std::time::Duration::from_secs(profile.wall_timeout_secs),
1135 max_response_bytes: profile.max_response_bytes,
1136 max_stderr_bytes: profile.max_stderr_bytes,
1137 address_space_bytes: None,
1138 cpu_seconds: profile.cpu_seconds,
1139 file_size_bytes: profile.file_size_bytes,
1140 max_processes: profile.max_processes,
1141 max_resident_bytes: Some(profile.max_resident_bytes),
1142 }
1143 }
1144
1145 #[cfg(feature = "audio-asr")]
1148 fn build_asr_runner(
1149 profile: &crate::convert::AsrProfile,
1150 exec_grants: Vec<std::path::PathBuf>,
1151 read_grants: Vec<std::path::PathBuf>,
1152 runtime_profile: crate::runner::jail::RuntimeProfile,
1153 ) -> Result<Runner, RunnerError> {
1154 Ok(Runner::with_grants(
1155 crate::runner::jail::platform_backend()?,
1156 crate::runner::locate_worker()?,
1157 asr_runner_limits(profile),
1158 exec_grants,
1159 read_grants,
1160 runtime_profile,
1161 ))
1162 }
1163
1164 #[cfg(feature = "audio-asr")]
1165 impl Converter for AsrAdapter {
1166 fn id(&self) -> &'static str {
1167 ASR_ADAPTER_ID
1168 }
1169
1170 fn version(&self) -> &'static str {
1171 ASR_ADAPTER_VERSION
1172 }
1173
1174 fn convert(
1175 &self,
1176 source: &[u8],
1177 detected_format: &str,
1178 ) -> std::result::Result<Outcome, ConvertError> {
1179 use crate::runner::protocol::bodies::{AsrOk, AsrRequest, InventoryEntry};
1180 if !matches!(detected_format, "wav" | "mp3" | "flac" | "m4a") {
1181 return Err(ConvertError {
1182 code: "unclaimed_format",
1183 message: format!("the ASR adapter does not handle {detected_format}"),
1184 });
1185 }
1186 let runner = self
1187 .runner
1188 .as_ref()
1189 .map_err(|e| runner_failure(e.clone()))?;
1190 let input = format!("input.{detected_format}");
1191 let mut wire_inventory = Vec::new();
1196 for (role, path) in &self.inventory {
1197 let (Some(path), Some(expected)) =
1198 (path.to_str(), self.profile.inventory.get(role))
1199 else {
1200 continue;
1201 };
1202 wire_inventory.push(InventoryEntry {
1203 role: role.clone(),
1204 path: path.to_string(),
1205 expected_blake3: expected.clone(),
1206 });
1207 }
1208 let body: AsrOk = call(
1209 runner,
1210 self.mode,
1211 &AsrRequest {
1212 input: input.clone(),
1213 language: Some(self.profile.language.clone()),
1214 max_duration_seconds: self.profile.max_duration_seconds,
1215 inventory: wire_inventory,
1216 },
1217 &[(&input, source)],
1218 )?;
1219 if body.segments.iter().any(|segment| segment.speaker != 1) {
1223 return Err(ConvertError {
1224 code: "asr-protocol-error",
1225 message: "a transcription segment carries an unexpected speaker label"
1226 .to_string(),
1227 });
1228 }
1229 let text = normalize_text(&render_transcript(&body.segments).map_err(|detail| {
1230 ConvertError {
1231 code: "adapter_error",
1232 message: detail,
1233 }
1234 })?);
1235 if !source.is_empty() && text.is_empty() {
1236 return Err(empty_output(source.len()));
1237 }
1238 let media = crate::manifest::Media {
1244 duration_seconds: body.duration_seconds,
1245 language: body
1246 .language
1247 .clone()
1248 .or_else(|| Some(self.profile.language.clone())),
1249 model_id: Some(ASR_ROLE_WEIGHTS.to_string()),
1250 model_hash: self.profile.inventory.get(ASR_ROLE_WEIGHTS).cloned(),
1251 decode_options_hash: self.profile.inventory.get(ASR_ROLE_DECODE_POLICY).cloned(),
1252 speaker_count: None,
1253 };
1254 Ok(Outcome {
1255 artifact_kind: ArtifactKind::Transcript,
1257 converter_id: ASR_ADAPTER_ID.to_string(),
1258 converter_version: ASR_ADAPTER_VERSION.to_string(),
1259 detected_format: detected_format.to_string(),
1260 segments: vec![Segment::span(0, text.len(), "document")],
1261 text,
1262 warnings: Vec::new(),
1263 media: Some(media),
1264 })
1265 }
1266 }
1267
1268 pub struct VideoAdapter {
1270 runner: Runner,
1271 }
1272
1273 impl VideoAdapter {
1274 pub fn new(runner: Runner) -> VideoAdapter {
1276 VideoAdapter { runner }
1277 }
1278 }
1279
1280 impl Converter for VideoAdapter {
1281 fn id(&self) -> &'static str {
1282 VIDEO_ADAPTER_ID
1283 }
1284
1285 fn version(&self) -> &'static str {
1286 VIDEO_ADAPTER_VERSION
1287 }
1288
1289 fn convert(
1290 &self,
1291 source: &[u8],
1292 detected_format: &str,
1293 ) -> std::result::Result<Outcome, ConvertError> {
1294 if !matches!(detected_format, "mp4" | "mov") {
1295 return Err(ConvertError {
1296 code: "unclaimed_format",
1297 message: format!("the video adapter does not handle {detected_format}"),
1298 });
1299 }
1300 let input = format!("input.{detected_format}");
1301 let body: VideoOk = call(
1302 &self.runner,
1303 "video",
1304 &VideoRequest {
1305 input: input.clone(),
1306 sample_interval_seconds: 1.0,
1307 max_frames: 10_000,
1308 },
1309 &[(&input, source)],
1310 )?;
1311 let text = normalize_text(&render_screen_states(&body.states).map_err(|detail| {
1312 ConvertError {
1313 code: "adapter_error",
1314 message: detail,
1315 }
1316 })?);
1317 if !source.is_empty() && text.is_empty() {
1318 return Err(empty_output(source.len()));
1319 }
1320 Ok(Outcome {
1321 artifact_kind: ArtifactKind::Text,
1322 converter_id: VIDEO_ADAPTER_ID.to_string(),
1323 converter_version: VIDEO_ADAPTER_VERSION.to_string(),
1324 detected_format: detected_format.to_string(),
1325 segments: vec![Segment::span(0, text.len(), "document")],
1326 text,
1327 warnings: Vec::new(),
1328 media: None,
1329 })
1330 }
1331 }
1332
1333 fn empty_output(source_len: usize) -> ConvertError {
1334 ConvertError {
1335 code: "empty_output",
1336 message: format!("source is {source_len} bytes but conversion produced no text"),
1337 }
1338 }
1339
1340 #[cfg(all(test, feature = "audio-asr"))]
1341 mod audio_grant_tests {
1342 use super::*;
1343
1344 #[test]
1349 fn a_supplied_but_unpinned_role_earns_no_grant_and_no_wire_entry() {
1350 let dir = tempfile::tempdir().unwrap();
1351 let make = |name: &str| {
1352 let path = dir.path().join(name);
1353 std::fs::write(&path, name).unwrap();
1354 path.canonicalize().unwrap()
1355 };
1356 let cli = make("cli");
1357 let weights = make("weights");
1358 let probe = make("probe");
1359 let mut pins = std::collections::BTreeMap::new();
1360 pins.insert(ASR_ROLE_CLI.to_string(), "0".repeat(64));
1361 pins.insert(ASR_ROLE_WEIGHTS.to_string(), "0".repeat(64));
1362 pins.insert(
1363 ASR_ROLE_DECODE_POLICY.to_string(),
1364 crate::hash::hash_bytes(ASR_DECODE_POLICY.as_bytes()),
1365 );
1366 let profile = crate::convert::AsrProfile {
1367 max_duration_seconds: 3600,
1368 wall_timeout_secs: 900,
1369 cpu_seconds: 600,
1370 max_resident_bytes: 6_442_450_944,
1371 max_response_bytes: 4_194_304,
1372 max_stderr_bytes: 4_194_304,
1373 file_size_bytes: 1_207_959_552,
1374 max_processes: 64,
1375 language: "en".to_string(),
1376 inventory: pins,
1377 };
1378 let mut supplied = crate::convert::RuntimeInventory::empty();
1379 supplied.set(ASR_ROLE_CLI, cli.clone()).unwrap();
1380 supplied.set(ASR_ROLE_WEIGHTS, weights.clone()).unwrap();
1381 supplied.set(ASR_ROLE_PROBE, probe.clone()).unwrap();
1385
1386 let files = pinned_asr_runtime_files(&profile, &supplied);
1387 assert_eq!(files.len(), 2, "{files:?}");
1388 assert!(files.iter().all(|(role, _)| role != ASR_ROLE_PROBE));
1389 let (exec_grants, read_grants) = asr_grant_lists(&files);
1390 assert_eq!(exec_grants, vec![cli.clone()]);
1391 assert_eq!(read_grants, vec![weights.clone()]);
1392
1393 let adapter = AsrAdapter::new(profile, &supplied);
1397 let runner = adapter
1398 .runner
1399 .as_ref()
1400 .expect("this platform has a jail backend");
1401 let (spec_exec, spec_read) = runner.grant_lists();
1402 assert_eq!(spec_exec, std::slice::from_ref(&cli));
1403 assert_eq!(spec_read, std::slice::from_ref(&weights));
1404 assert!(!spec_exec.contains(&probe) && !spec_read.contains(&probe));
1405 assert_eq!(
1408 runner.runtime_profile(),
1409 crate::runner::jail::RuntimeProfile::Accelerator
1410 );
1411 }
1412
1413 #[cfg(feature = "test-adapters")]
1416 #[test]
1417 fn the_fake_transcription_mode_keeps_the_base_profile() {
1418 let profile = crate::convert::AsrProfile {
1419 max_duration_seconds: 3600,
1420 wall_timeout_secs: 900,
1421 cpu_seconds: 600,
1422 max_resident_bytes: 6_442_450_944,
1423 max_response_bytes: 4_194_304,
1424 max_stderr_bytes: 4_194_304,
1425 file_size_bytes: 1_207_959_552,
1426 max_processes: 64,
1427 language: "en".to_string(),
1428 inventory: std::collections::BTreeMap::new(),
1429 };
1430 let adapter = AsrAdapter::new_fake(profile);
1431 let runner = adapter
1432 .runner
1433 .as_ref()
1434 .expect("this platform has a jail backend");
1435 assert_eq!(
1436 runner.runtime_profile(),
1437 crate::runner::jail::RuntimeProfile::Plain
1438 );
1439 }
1440 }
1441
1442 #[cfg(all(test, feature = "svg-provider"))]
1443 mod svg_provider_grant_tests {
1444 use super::*;
1445
1446 #[test]
1452 fn the_provider_leg_grants_exactly_what_the_configuration_named() {
1453 let dir = tempfile::tempdir().unwrap();
1454 let root = dir.path().canonicalize().unwrap();
1455 let make = |name: &str| {
1456 let path = root.join(name);
1457 std::fs::write(&path, name).unwrap();
1458 path
1459 };
1460 let raster = make("raster");
1461 let encoder = make("encoder");
1462 let closure = root.clone();
1465 let text = format!(
1466 "schema = \"{schema}\"\n\n\
1467 [providers.svg.\"{raster_role}\"]\n\
1468 path = \"{raster}\"\n\
1469 closure_roots = [\"{closure}\"]\n\
1470 jail_service_prefix = \"^ex\\\\.ample\\\\.\"\n\
1471 jail_temp_env = \"EXAMPLE_TMPDIR\"\n\n\
1472 [providers.svg.\"{encoder_role}\"]\n\
1473 path = \"{encoder}\"\n",
1474 schema = crate::convert::provider::PROVIDER_SCHEMA,
1475 raster_role = crate::convert::provider::PROVIDER_ROLE_RASTER,
1476 encoder_role = crate::convert::provider::PROVIDER_ROLE_ENCODER,
1477 raster = raster.display(),
1478 encoder = encoder.display(),
1479 closure = closure.display(),
1480 );
1481 let mut limits = crate::convert::ImageOcrLimits::default();
1482 for (label, version) in [
1483 (crate::convert::provider::PROVIDER_ROLE_RASTER, "1.2.3"),
1484 (crate::convert::provider::PROVIDER_ROLE_ENCODER, "4.5.6"),
1485 ] {
1486 limits.svg_provider.insert(
1487 label.to_string(),
1488 crate::convert::provider::ProviderPin {
1489 blake3: "0".repeat(64),
1490 version: version.to_string(),
1491 closure_blake3: None,
1492 },
1493 );
1494 }
1495 let config = crate::convert::provider::ProviderConfig::parse(&text).unwrap();
1496 let svg = config.svg().expect("both roles");
1497 let adapter = ImagePixelOcr::with_svg_provider(
1498 limits,
1499 &crate::convert::RuntimeInventory::empty(),
1500 svg,
1501 );
1502 let leg = adapter.svg.as_ref().expect("the leg is wired");
1503 let runner = leg
1504 .runner
1505 .as_ref()
1506 .expect("this platform has a jail backend");
1507 let (exec, read) = runner.grant_lists();
1508 assert_eq!(exec, [raster.clone(), encoder.clone()]);
1509 assert!(read.is_empty(), "{read:?}");
1510 assert_eq!(
1511 runner.runtime_profile(),
1512 crate::runner::jail::RuntimeProfile::Provider
1513 );
1514 let jail = runner.provider_jail().expect("the provider parameters");
1515 assert_eq!(jail.closures(), std::slice::from_ref(&closure));
1516 let _ = &encoder;
1517 assert_eq!(jail.service_prefix(), Some(r"^ex\.ample\."));
1518 assert_eq!(jail.temp_env(), Some("EXAMPLE_TMPDIR"));
1519 let roles: Vec<&str> = leg.roles.iter().map(|entry| entry.role.as_str()).collect();
1522 assert_eq!(
1523 roles,
1524 vec![
1525 crate::convert::provider::PROVIDER_ROLE_RASTER,
1526 crate::convert::provider::PROVIDER_ROLE_ENCODER
1527 ]
1528 );
1529 assert_eq!(leg.roles[0].version, "1.2.3");
1530 assert_eq!(leg.roles[1].version, "4.5.6");
1531 }
1532
1533 #[test]
1537 fn a_configured_provider_never_moves_the_raster_path_off_the_base_class() {
1538 let adapter = ImagePixelOcr::new(
1539 crate::convert::ImageOcrLimits::default(),
1540 &crate::convert::RuntimeInventory::empty(),
1541 );
1542 let runner = adapter
1543 .runner
1544 .as_ref()
1545 .expect("this platform has a jail backend");
1546 assert_eq!(
1547 runner.runtime_profile(),
1548 crate::runner::jail::RuntimeProfile::Plain
1549 );
1550 assert!(runner.provider_jail().is_none());
1551 }
1552 }
1553
1554 #[cfg(all(test, feature = "image-ocr"))]
1555 mod image_grant_tests {
1556 use super::*;
1557
1558 #[test]
1563 fn an_image_worker_with_an_engine_grant_never_asks_for_accelerator_allowances() {
1564 let dir = tempfile::tempdir().unwrap();
1565 let engine = dir.path().join("engine");
1566 std::fs::write(&engine, b"engine bytes").unwrap();
1567 let engine = engine.canonicalize().unwrap();
1568 let mut limits = crate::convert::ImageOcrLimits::default();
1569 limits
1570 .inventory
1571 .insert("engine-cli".to_string(), "0".repeat(64));
1572 let mut supplied = crate::convert::RuntimeInventory::empty();
1573 supplied.set("engine-cli", engine.clone()).unwrap();
1574 let adapter = ImagePixelOcr::new(limits, &supplied);
1575 let runner = adapter
1576 .runner
1577 .as_ref()
1578 .expect("this platform has a jail backend");
1579 let (spec_exec, _) = runner.grant_lists();
1580 assert_eq!(spec_exec, std::slice::from_ref(&engine));
1581 assert_eq!(
1584 runner.runtime_profile(),
1585 crate::runner::jail::RuntimeProfile::Plain
1586 );
1587 }
1588 }
1589}
1590
1591#[cfg(all(unix, feature = "audio-asr"))]
1592pub use imp::AsrAdapter;
1593#[cfg(all(unix, feature = "image-metadata"))]
1594pub use imp::ImageMetadata;
1595#[cfg(all(unix, feature = "image-ocr"))]
1596pub use imp::ImagePixelOcr;
1597#[cfg(unix)]
1598pub use imp::{OcrAdapter, PdfSubprocess, RecordsSubprocess, VideoAdapter};
1599
1600#[cfg(not(unix))]
1601mod imp {
1602 use super::*;
1603 use crate::convert::RecordsLimits;
1604
1605 fn no_backend() -> ConvertError {
1606 ConvertError {
1607 code: "sandbox_unavailable",
1608 message: "no jail backend exists for this platform, refusing to run adapters"
1609 .to_string(),
1610 }
1611 }
1612
1613 pub struct PdfSubprocess;
1616
1617 impl Converter for PdfSubprocess {
1618 fn id(&self) -> &'static str {
1619 PDF_SUBPROCESS_ID
1620 }
1621
1622 fn version(&self) -> &'static str {
1623 PDF_SUBPROCESS_VERSION
1624 }
1625
1626 fn convert(
1627 &self,
1628 _source: &[u8],
1629 _detected_format: &str,
1630 ) -> std::result::Result<Outcome, ConvertError> {
1631 Err(no_backend())
1632 }
1633 }
1634
1635 pub struct RecordsSubprocess;
1638
1639 impl RecordsSubprocess {
1640 pub fn new(_limits: RecordsLimits) -> RecordsSubprocess {
1642 RecordsSubprocess
1643 }
1644 }
1645
1646 impl Converter for RecordsSubprocess {
1647 fn id(&self) -> &'static str {
1648 RECORDS_SUBPROCESS_ID
1649 }
1650
1651 fn version(&self) -> &'static str {
1652 RECORDS_SUBPROCESS_VERSION
1653 }
1654
1655 fn convert(
1656 &self,
1657 _source: &[u8],
1658 _detected_format: &str,
1659 ) -> std::result::Result<Outcome, ConvertError> {
1660 Err(no_backend())
1661 }
1662 }
1663}
1664
1665#[cfg(not(unix))]
1666pub use imp::{PdfSubprocess, RecordsSubprocess};
1667
1668pub fn render_transcript(segments: &[crate::runner_bodies::AsrSegment]) -> Result<String, String> {
1672 let mut ordered: Vec<_> = segments.iter().collect();
1673 ordered.sort_by(|a, b| {
1674 a.start_seconds
1675 .total_cmp(&b.start_seconds)
1676 .then(a.end_seconds.total_cmp(&b.end_seconds))
1677 .then(a.speaker.cmp(&b.speaker))
1678 });
1679 let mut lines = Vec::new();
1680 for segment in ordered {
1681 if segment.end_seconds < segment.start_seconds {
1682 return Err(format!(
1683 "segment ends at {} before it starts at {}",
1684 segment.end_seconds, segment.start_seconds
1685 ));
1686 }
1687 lines.push(format!(
1688 "[{} -> {}] Speaker {}: {}",
1689 format_timecode(segment.start_seconds)?,
1690 format_timecode(segment.end_seconds)?,
1691 segment.speaker,
1692 single_line(&segment.text)
1693 ));
1694 }
1695 Ok(finish_lines(lines))
1696}
1697
1698pub fn render_screen_states(
1702 states: &[crate::runner_bodies::ScreenState],
1703) -> Result<String, String> {
1704 let mut ordered: Vec<_> = states.iter().collect();
1705 ordered.sort_by(|a, b| {
1706 a.first_seen_seconds
1707 .total_cmp(&b.first_seen_seconds)
1708 .then(a.text.cmp(&b.text))
1709 });
1710 let mut lines = Vec::new();
1711 let mut previous: Option<String> = None;
1712 for state in ordered {
1713 let text = single_line(&state.text);
1714 if previous.as_deref() == Some(text.as_str()) {
1715 continue;
1716 }
1717 lines.push(format!(
1718 "[on-screen @ {}] {}",
1719 format_timecode(state.first_seen_seconds)?,
1720 text
1721 ));
1722 previous = Some(text);
1723 }
1724 Ok(finish_lines(lines))
1725}
1726
1727fn finish_lines(lines: Vec<String>) -> String {
1728 if lines.is_empty() {
1729 String::new()
1730 } else {
1731 let mut text = lines.join("\n");
1732 text.push('\n');
1733 text
1734 }
1735}
1736
1737#[cfg(all(unix, feature = "image-ocr"))]
1752pub(crate) fn render_ocr_spans(
1753 spans: &[crate::runner::protocol::bodies::OcrSpan],
1754 source_len: usize,
1755) -> Result<(String, Vec<String>), ConvertError> {
1756 let mut warnings = Vec::new();
1757 let mut lines = Vec::new();
1758 for (index, span) in spans.iter().enumerate() {
1759 if !(0.0..=1.0).contains(&span.confidence) {
1760 return Err(ConvertError {
1761 code: "ocr-protocol-error",
1762 message: format!(
1763 "span {index} confidence {} is outside 0 to 1",
1764 span.confidence
1765 ),
1766 });
1767 }
1768 if span.confidence < OCR_CONFIDENCE_WARNING {
1769 warnings.push(format!(
1770 "ocr_low_confidence: span {index} at {:.2}",
1771 span.confidence
1772 ));
1773 }
1774 lines.push(single_line(&span.text));
1775 }
1776 let text = normalize_text(&finish_lines(lines));
1777 if source_len > 0 && !text.chars().any(crate::convert::is_meaningful) {
1778 return Err(ConvertError {
1779 code: "empty_output",
1780 message: format!("source is {source_len} bytes but conversion produced no text"),
1781 });
1782 }
1783 Ok((text, warnings))
1784}
1785
1786#[cfg(test)]
1787mod tests {
1788 use super::*;
1789 use crate::runner_bodies::{AsrSegment, ScreenState};
1790
1791 #[test]
1792 fn recovered_pdf_text_is_recorded_under_the_recovery_id() {
1793 assert_eq!(
1797 pdf_converter_identity(false),
1798 (PDF_SUBPROCESS_ID, PDF_SUBPROCESS_VERSION)
1799 );
1800 assert_eq!(
1801 pdf_converter_identity(true),
1802 (PDF_RECOVERED_ID, PDF_RECOVERED_VERSION)
1803 );
1804 assert_ne!(PDF_RECOVERED_ID, PDF_SUBPROCESS_ID);
1805 assert_eq!(PDF_RECOVERED_VERSION, PDF_SUBPROCESS_VERSION);
1806 }
1807
1808 #[test]
1809 fn timecodes_render_as_hours_minutes_seconds() {
1810 assert_eq!(format_timecode(0.0).unwrap(), "00:00:00");
1811 assert_eq!(format_timecode(12.9).unwrap(), "00:00:12");
1812 assert_eq!(format_timecode(182.0).unwrap(), "00:03:02");
1813 assert_eq!(format_timecode(3661.0).unwrap(), "01:01:01");
1814 assert!(format_timecode(-1.0).is_err());
1815 assert!(format_timecode(f64::NAN).is_err());
1816 }
1817
1818 #[test]
1819 fn transcripts_render_one_ordered_line_per_segment() {
1820 let segments = vec![
1821 AsrSegment {
1822 start_seconds: 19.0,
1823 end_seconds: 24.0,
1824 speaker: 2,
1825 text: "second\nline".to_string(),
1826 },
1827 AsrSegment {
1828 start_seconds: 12.0,
1829 end_seconds: 19.0,
1830 speaker: 1,
1831 text: "first remark".to_string(),
1832 },
1833 ];
1834 assert_eq!(
1835 render_transcript(&segments).unwrap(),
1836 "[00:00:12 -> 00:00:19] Speaker 1: first remark\n\
1837 [00:00:19 -> 00:00:24] Speaker 2: second line\n"
1838 );
1839 }
1840
1841 #[test]
1842 fn transcripts_reject_a_segment_that_ends_before_it_starts() {
1843 let segments = vec![AsrSegment {
1844 start_seconds: 5.0,
1845 end_seconds: 4.0,
1846 speaker: 1,
1847 text: "impossible".to_string(),
1848 }];
1849 assert!(render_transcript(&segments).is_err());
1850 }
1851
1852 #[test]
1853 fn screen_states_render_deduplicated_in_order() {
1854 let states = vec![
1855 ScreenState {
1856 first_seen_seconds: 182.0,
1857 text: "Quarterly revenue".to_string(),
1858 },
1859 ScreenState {
1860 first_seen_seconds: 10.0,
1861 text: "Agenda".to_string(),
1862 },
1863 ScreenState {
1864 first_seen_seconds: 200.0,
1865 text: "Quarterly revenue".to_string(),
1866 },
1867 ];
1868 assert_eq!(
1869 render_screen_states(&states).unwrap(),
1870 "[on-screen @ 00:00:10] Agenda\n[on-screen @ 00:03:02] Quarterly revenue\n"
1871 );
1872 }
1873}