1use std::fmt;
36
37#[cfg(any(
38 feature = "ocr-mistral",
39 feature = "ocr-google",
40 feature = "ocr-aws",
41 feature = "ocr-azure"
42))]
43use std::io::Cursor;
44
45#[cfg(feature = "ocr-azure")]
46use std::time::Duration;
47
48#[cfg(any(feature = "ocr-mistral", feature = "ocr-google", feature = "ocr-aws"))]
49use base64::Engine as _;
50#[cfg(feature = "ocr-aws")]
51use hmac::{Hmac, Mac};
52#[cfg(any(
53 feature = "ocr-mistral",
54 feature = "ocr-google",
55 feature = "ocr-aws",
56 feature = "ocr-azure"
57))]
58use image::{DynamicImage, ImageFormat, RgbImage};
59#[cfg(feature = "ocr-google")]
60use pkcs8::DecodePrivateKey as _;
61#[cfg(feature = "ocr-google")]
62use rsa::{
63 pkcs1v15::SigningKey,
64 signature::{SignatureEncoding as _, Signer as _},
65 RsaPrivateKey,
66};
67#[cfg(any(
68 feature = "ocr-mistral",
69 feature = "ocr-google",
70 feature = "ocr-aws",
71 feature = "ocr-azure"
72))]
73use serde::{Deserialize, Serialize};
74#[cfg(feature = "ocr-aws")]
75use sha2::Digest as _;
76#[cfg(any(feature = "ocr-google", feature = "ocr-aws"))]
77use sha2::Sha256;
78#[cfg(feature = "ocr-aws")]
79use time::macros::format_description;
80#[cfg(any(feature = "ocr-google", feature = "ocr-aws"))]
81use time::OffsetDateTime;
82
83#[derive(Debug, Clone)]
87pub struct OcrResult {
88 pub text: String,
90 pub words: Vec<OcrWord>,
92 pub confidence: f32,
94}
95
96#[derive(Debug, Clone)]
98pub struct OcrWord {
99 pub text: String,
101 pub bbox: [f32; 4],
103 pub confidence: f32,
105}
106
107#[derive(Debug)]
109pub enum OcrError {
110 NoEngine,
112 ImageError(String),
114 RecognitionFailed(String),
116}
117
118impl fmt::Display for OcrError {
119 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120 match self {
121 OcrError::NoEngine => write!(f, "no OCR engine available"),
122 OcrError::ImageError(e) => write!(f, "image error: {e}"),
123 OcrError::RecognitionFailed(e) => write!(f, "recognition failed: {e}"),
124 }
125 }
126}
127
128impl std::error::Error for OcrError {}
129
130pub trait OcrBackend: Send + Sync {
136 fn recognize(&self, image_data: &[u8], width: u32, height: u32) -> Result<OcrResult, OcrError>;
143
144 fn name(&self) -> &str;
146}
147
148#[cfg(any(
151 feature = "ocr-mistral",
152 feature = "ocr-google",
153 feature = "ocr-aws",
154 feature = "ocr-azure"
155))]
156fn blocking_http_client() -> reqwest::blocking::Client {
157 reqwest::blocking::Client::builder()
158 .timeout(std::time::Duration::from_secs(60))
159 .build()
160 .expect("building reqwest blocking client")
161}
162
163#[cfg(any(
164 feature = "ocr-mistral",
165 feature = "ocr-google",
166 feature = "ocr-aws",
167 feature = "ocr-azure"
168))]
169fn expected_rgb_len(width: u32, height: u32) -> Result<usize, OcrError> {
170 (width as usize)
171 .checked_mul(height as usize)
172 .and_then(|px| px.checked_mul(3))
173 .ok_or_else(|| OcrError::ImageError("image dimensions overflowed".into()))
174}
175
176#[cfg(any(
177 feature = "ocr-mistral",
178 feature = "ocr-google",
179 feature = "ocr-aws",
180 feature = "ocr-azure"
181))]
182fn rgb_png_bytes(image_data: &[u8], width: u32, height: u32) -> Result<Vec<u8>, OcrError> {
183 let expected_len = expected_rgb_len(width, height)?;
184 if image_data.len() != expected_len {
185 return Err(OcrError::ImageError(format!(
186 "expected {expected_len} RGB bytes, got {}",
187 image_data.len()
188 )));
189 }
190
191 let image = RgbImage::from_raw(width, height, image_data.to_vec()).ok_or_else(|| {
192 OcrError::ImageError("failed to create RGB image from raw OCR buffer".into())
193 })?;
194
195 let mut cursor = Cursor::new(Vec::new());
196 DynamicImage::ImageRgb8(image)
197 .write_to(&mut cursor, ImageFormat::Png)
198 .map_err(|e| OcrError::ImageError(format!("encode PNG for OCR request: {e}")))?;
199
200 Ok(cursor.into_inner())
201}
202
203#[cfg(any(feature = "ocr-mistral", feature = "ocr-google", feature = "ocr-aws"))]
204fn rgb_base64_png(image_data: &[u8], width: u32, height: u32) -> Result<String, OcrError> {
205 let png_bytes = rgb_png_bytes(image_data, width, height)?;
206 Ok(base64::engine::general_purpose::STANDARD.encode(png_bytes))
207}
208
209#[cfg(feature = "ocr-mistral")]
210fn rgb_data_url(image_data: &[u8], width: u32, height: u32) -> Result<String, OcrError> {
211 let encoded = rgb_base64_png(image_data, width, height)?;
212 Ok(format!("data:image/png;base64,{encoded}"))
213}
214
215#[cfg(any(feature = "ocr-google", feature = "ocr-azure"))]
216fn bbox_from_points(points: &[(f32, f32)]) -> [f32; 4] {
217 if points.is_empty() {
218 return [0.0, 0.0, 0.0, 0.0];
219 }
220
221 let mut min_x = f32::INFINITY;
222 let mut min_y = f32::INFINITY;
223 let mut max_x = f32::NEG_INFINITY;
224 let mut max_y = f32::NEG_INFINITY;
225
226 for &(x, y) in points {
227 min_x = min_x.min(x);
228 min_y = min_y.min(y);
229 max_x = max_x.max(x);
230 max_y = max_y.max(y);
231 }
232
233 [min_x, min_y, max_x - min_x, max_y - min_y]
234}
235
236#[cfg(any(feature = "ocr-google", feature = "ocr-aws", feature = "ocr-azure"))]
237fn confidence_from_words(words: &[OcrWord], fallback: f32) -> f32 {
238 if words.is_empty() {
239 fallback
240 } else {
241 words.iter().map(|word| word.confidence).sum::<f32>() / words.len() as f32
242 }
243}
244
245#[cfg(any(feature = "ocr-google", feature = "ocr-aws", feature = "ocr-azure"))]
246fn home_dir() -> Option<std::path::PathBuf> {
247 std::env::var_os("HOME")
248 .map(std::path::PathBuf::from)
249 .or_else(|| std::env::var_os("USERPROFILE").map(std::path::PathBuf::from))
250}
251
252#[cfg(feature = "ocr-mistral")]
255const MISTRAL_OCR_ENDPOINT: &str = "https://api.mistral.ai/v1/ocr";
256#[cfg(feature = "ocr-mistral")]
257const MISTRAL_OCR_MODEL: &str = "mistral-ocr-latest";
258
259#[cfg(feature = "ocr-mistral")]
261pub struct MistralOcrBackend {
262 api_key: String,
263 client: reqwest::blocking::Client,
264}
265
266#[cfg(feature = "ocr-mistral")]
267impl MistralOcrBackend {
268 pub fn new(api_key: &str) -> Self {
270 Self {
271 api_key: api_key.to_string(),
272 client: blocking_http_client(),
273 }
274 }
275
276 pub fn from_env() -> Result<Self, OcrError> {
278 let key = std::env::var("MISTRAL_API_KEY").map_err(|_| OcrError::NoEngine)?;
279 Ok(Self::new(&key))
280 }
281}
282
283#[cfg(feature = "ocr-mistral")]
284impl OcrBackend for MistralOcrBackend {
285 fn recognize(&self, image_data: &[u8], width: u32, height: u32) -> Result<OcrResult, OcrError> {
286 let image_url = rgb_data_url(image_data, width, height)?;
287 let model =
288 std::env::var("MISTRAL_OCR_MODEL").unwrap_or_else(|_| MISTRAL_OCR_MODEL.to_string());
289 let body = MistralOcrRequest {
290 model: &model,
291 document: MistralOcrDocument {
292 kind: "image_url",
293 image_url: &image_url,
294 },
295 };
296 let body_json = serde_json::to_vec(&body)
297 .map_err(|e| OcrError::RecognitionFailed(format!("serialize request: {e}")))?;
298
299 let response = self
300 .client
301 .post(MISTRAL_OCR_ENDPOINT)
302 .header(
303 reqwest::header::AUTHORIZATION,
304 format!("Bearer {}", self.api_key),
305 )
306 .header(reqwest::header::CONTENT_TYPE, "application/json")
307 .body(body_json)
308 .send()
309 .map_err(|e| OcrError::RecognitionFailed(format!("Mistral OCR request failed: {e}")))?;
310
311 let status = response.status();
312 let response_text = response
313 .text()
314 .map_err(|e| OcrError::RecognitionFailed(format!("read Mistral OCR response: {e}")))?;
315
316 if !status.is_success() {
317 return Err(OcrError::RecognitionFailed(format!(
318 "Mistral OCR returned {status}: {response_text}"
319 )));
320 }
321
322 let parsed: MistralOcrResponse = serde_json::from_str(&response_text)
323 .map_err(|e| OcrError::RecognitionFailed(format!("parse Mistral OCR response: {e}")))?;
324 let text = mistral_markdown_text(&parsed);
325 let confidence = if text.trim().is_empty() { 0.0 } else { 0.95 };
326
327 Ok(OcrResult {
328 text,
329 words: Vec::new(),
330 confidence,
331 })
332 }
333
334 fn name(&self) -> &str {
335 "mistral"
336 }
337}
338
339#[cfg(feature = "ocr-mistral")]
340#[derive(Serialize)]
341struct MistralOcrRequest<'a> {
342 model: &'a str,
343 document: MistralOcrDocument<'a>,
344}
345
346#[cfg(feature = "ocr-mistral")]
347#[derive(Serialize)]
348struct MistralOcrDocument<'a> {
349 #[serde(rename = "type")]
350 kind: &'a str,
351 image_url: &'a str,
352}
353
354#[cfg(feature = "ocr-mistral")]
355#[derive(Deserialize)]
356struct MistralOcrResponse {
357 #[serde(default)]
358 pages: Vec<MistralOcrPage>,
359}
360
361#[cfg(feature = "ocr-mistral")]
362#[derive(Deserialize)]
363struct MistralOcrPage {
364 markdown: Option<String>,
365}
366
367#[cfg(feature = "ocr-mistral")]
368fn mistral_markdown_text(response: &MistralOcrResponse) -> String {
369 response
370 .pages
371 .iter()
372 .filter_map(|page| page.markdown.as_deref())
373 .map(str::trim)
374 .filter(|markdown| !markdown.is_empty())
375 .collect::<Vec<_>>()
376 .join("\n\n")
377}
378
379#[cfg(feature = "ocr-google")]
382const GOOGLE_VISION_ENDPOINT: &str = "https://vision.googleapis.com/v1/images:annotate";
383#[cfg(feature = "ocr-google")]
384const GOOGLE_TOKEN_ENDPOINT: &str = "https://oauth2.googleapis.com/token";
385#[cfg(feature = "ocr-google")]
386const GOOGLE_OAUTH_SCOPE: &str = "https://www.googleapis.com/auth/cloud-platform";
387
388#[cfg(feature = "ocr-google")]
395pub struct GoogleVisionBackend {
396 api_key: Option<String>,
397 service_account_json: Option<String>,
398 client: reqwest::blocking::Client,
399 endpoint: String,
400}
401
402#[cfg(feature = "ocr-google")]
403impl GoogleVisionBackend {
404 pub fn from_api_key(key: &str) -> Self {
406 Self::with_auth(
407 Some(key.to_string()),
408 None,
409 GOOGLE_VISION_ENDPOINT.to_string(),
410 )
411 }
412
413 pub fn from_service_account(json_path: &str) -> Result<Self, OcrError> {
418 let json = std::fs::read_to_string(json_path).map_err(|e| {
419 OcrError::RecognitionFailed(format!(
420 "read Google service-account JSON from {json_path}: {e}"
421 ))
422 })?;
423 let credentials = parse_google_credentials(&json)?;
424 match credentials.kind.as_deref() {
425 Some("service_account") => Ok(Self::with_auth(
426 None,
427 Some(json),
428 GOOGLE_VISION_ENDPOINT.to_string(),
429 )),
430 Some(other) => Err(OcrError::RecognitionFailed(format!(
431 "expected Google service-account JSON, found credentials type {other}"
432 ))),
433 None => Err(OcrError::RecognitionFailed(
434 "Google service-account JSON is missing the `type` field".into(),
435 )),
436 }
437 }
438
439 pub fn from_env() -> Result<Self, OcrError> {
447 if let Ok(key) = std::env::var("GOOGLE_VISION_API_KEY") {
448 if !key.trim().is_empty() {
449 return Ok(Self::from_api_key(&key));
450 }
451 }
452
453 if let Ok(path) = std::env::var("GOOGLE_APPLICATION_CREDENTIALS") {
454 return Self::from_credentials_file(&path);
455 }
456
457 if std::env::var_os("GOOGLE_CLOUD_PROJECT").is_some() {
458 if let Some(path) = google_application_default_credentials_path() {
459 if path.is_file() {
460 return Self::from_credentials_path(path);
461 }
462 }
463 }
464
465 Err(OcrError::NoEngine)
466 }
467
468 fn with_auth(
469 api_key: Option<String>,
470 service_account_json: Option<String>,
471 endpoint: String,
472 ) -> Self {
473 Self {
474 api_key,
475 service_account_json,
476 client: blocking_http_client(),
477 endpoint,
478 }
479 }
480
481 fn from_credentials_file(path: &str) -> Result<Self, OcrError> {
482 Self::from_credentials_path(std::path::PathBuf::from(path))
483 }
484
485 fn from_credentials_path(path: impl AsRef<std::path::Path>) -> Result<Self, OcrError> {
486 let path = path.as_ref();
487 let json = std::fs::read_to_string(path).map_err(|e| {
488 OcrError::RecognitionFailed(format!(
489 "read Google application credentials from {}: {e}",
490 path.display()
491 ))
492 })?;
493 let credentials = parse_google_credentials(&json)?;
494 match credentials.kind.as_deref() {
495 Some("service_account") | Some("authorized_user") => Ok(Self::with_auth(
496 None,
497 Some(json),
498 GOOGLE_VISION_ENDPOINT.to_string(),
499 )),
500 Some(other) => Err(OcrError::RecognitionFailed(format!(
501 "unsupported Google credentials type {other}"
502 ))),
503 None if credentials.refresh_token.is_some() => Ok(Self::with_auth(
504 None,
505 Some(json),
506 GOOGLE_VISION_ENDPOINT.to_string(),
507 )),
508 None => Err(OcrError::RecognitionFailed(
509 "Google credentials JSON is missing both `type` and refresh-token fields".into(),
510 )),
511 }
512 }
513
514 #[cfg(test)]
515 fn with_endpoint_api_key(key: &str, endpoint: &str) -> Self {
516 Self::with_auth(Some(key.to_string()), None, endpoint.to_string())
517 }
518
519 #[cfg(test)]
520 fn with_endpoint_credentials(credentials_json: &str, endpoint: &str) -> Self {
521 Self::with_auth(
522 None,
523 Some(credentials_json.to_string()),
524 endpoint.to_string(),
525 )
526 }
527}
528
529#[cfg(feature = "ocr-google")]
530impl OcrBackend for GoogleVisionBackend {
531 fn recognize(&self, image_data: &[u8], width: u32, height: u32) -> Result<OcrResult, OcrError> {
532 let encoded = rgb_base64_png(image_data, width, height)?;
533 let request = GoogleVisionAnnotateEnvelopeRequest {
534 requests: vec![GoogleVisionAnnotateRequest {
535 image: GoogleVisionAnnotateImage { content: &encoded },
536 features: vec![GoogleVisionAnnotateFeature {
537 kind: "TEXT_DETECTION",
538 }],
539 }],
540 };
541 let body_json = serde_json::to_vec(&request).map_err(|e| {
542 OcrError::RecognitionFailed(format!("serialize Google Vision request: {e}"))
543 })?;
544
545 let mut http = self
546 .client
547 .post(&self.endpoint)
548 .header(reqwest::header::CONTENT_TYPE, "application/json");
549
550 if let Some(api_key) = &self.api_key {
551 http = http.query(&[("key", api_key.as_str())]);
552 } else if let Some(credentials_json) = &self.service_account_json {
553 let access_token = google_access_token(&self.client, credentials_json)?;
554 http = http.header(
555 reqwest::header::AUTHORIZATION,
556 format!("Bearer {access_token}"),
557 );
558 } else {
559 return Err(OcrError::NoEngine);
560 }
561
562 let response = http.body(body_json).send().map_err(|e| {
563 OcrError::RecognitionFailed(format!("Google Vision OCR request failed: {e}"))
564 })?;
565
566 let status = response.status();
567 let response_text = response.text().map_err(|e| {
568 OcrError::RecognitionFailed(format!("read Google Vision OCR response: {e}"))
569 })?;
570
571 if !status.is_success() {
572 return Err(OcrError::RecognitionFailed(format!(
573 "Google Vision returned {status}: {response_text}"
574 )));
575 }
576
577 let parsed: GoogleVisionAnnotateEnvelopeResponse = serde_json::from_str(&response_text)
578 .map_err(|e| {
579 OcrError::RecognitionFailed(format!("parse Google Vision OCR response: {e}"))
580 })?;
581 let response = parsed.responses.into_iter().next().ok_or_else(|| {
582 OcrError::RecognitionFailed("Google Vision response did not contain any entries".into())
583 })?;
584
585 if let Some(error) = response.error {
586 let message = error
587 .message
588 .unwrap_or_else(|| "unknown Google Vision error".into());
589 return Err(OcrError::RecognitionFailed(format!(
590 "Google Vision OCR error: {message}"
591 )));
592 }
593
594 let text = response
595 .full_text_annotation
596 .as_ref()
597 .and_then(|annotation| annotation.text.clone())
598 .or_else(|| {
599 response
600 .text_annotations
601 .first()
602 .map(|annotation| annotation.description.clone())
603 })
604 .unwrap_or_default();
605
606 let words = response
607 .text_annotations
608 .into_iter()
609 .enumerate()
610 .filter_map(|(index, annotation)| {
611 if index == 0 {
612 return None;
613 }
614
615 let text = annotation.description.trim().to_string();
616 if text.is_empty() {
617 return None;
618 }
619
620 let bbox = annotation
621 .bounding_poly
622 .map(|poly| {
623 let points = poly
624 .vertices
625 .into_iter()
626 .map(|vertex| (vertex.x.unwrap_or(0.0), vertex.y.unwrap_or(0.0)))
627 .collect::<Vec<_>>();
628 bbox_from_points(&points)
629 })
630 .unwrap_or([0.0, 0.0, 0.0, 0.0]);
631
632 Some(OcrWord {
633 text,
634 bbox,
635 confidence: 1.0,
636 })
637 })
638 .collect::<Vec<_>>();
639
640 let confidence = if text.trim().is_empty() {
641 0.0
642 } else {
643 confidence_from_words(&words, 1.0)
644 };
645
646 Ok(OcrResult {
647 text,
648 words,
649 confidence,
650 })
651 }
652
653 fn name(&self) -> &str {
654 "google-vision"
655 }
656}
657
658#[cfg(feature = "ocr-google")]
659#[derive(Serialize)]
660struct GoogleVisionAnnotateEnvelopeRequest<'a> {
661 requests: Vec<GoogleVisionAnnotateRequest<'a>>,
662}
663
664#[cfg(feature = "ocr-google")]
665#[derive(Serialize)]
666struct GoogleVisionAnnotateRequest<'a> {
667 image: GoogleVisionAnnotateImage<'a>,
668 features: Vec<GoogleVisionAnnotateFeature<'a>>,
669}
670
671#[cfg(feature = "ocr-google")]
672#[derive(Serialize)]
673struct GoogleVisionAnnotateImage<'a> {
674 content: &'a str,
675}
676
677#[cfg(feature = "ocr-google")]
678#[derive(Serialize)]
679struct GoogleVisionAnnotateFeature<'a> {
680 #[serde(rename = "type")]
681 kind: &'a str,
682}
683
684#[cfg(feature = "ocr-google")]
685#[derive(Deserialize)]
686struct GoogleVisionAnnotateEnvelopeResponse {
687 #[serde(default)]
688 responses: Vec<GoogleVisionAnnotateResponse>,
689}
690
691#[cfg(feature = "ocr-google")]
692#[derive(Deserialize)]
693struct GoogleVisionAnnotateResponse {
694 #[serde(rename = "fullTextAnnotation")]
695 full_text_annotation: Option<GoogleVisionFullTextAnnotation>,
696 #[serde(rename = "textAnnotations", default)]
697 text_annotations: Vec<GoogleVisionTextAnnotation>,
698 error: Option<GoogleVisionError>,
699}
700
701#[cfg(feature = "ocr-google")]
702#[derive(Deserialize)]
703struct GoogleVisionFullTextAnnotation {
704 text: Option<String>,
705}
706
707#[cfg(feature = "ocr-google")]
708#[derive(Deserialize)]
709struct GoogleVisionTextAnnotation {
710 description: String,
711 #[serde(rename = "boundingPoly")]
712 bounding_poly: Option<GoogleVisionBoundingPoly>,
713}
714
715#[cfg(feature = "ocr-google")]
716#[derive(Deserialize)]
717struct GoogleVisionBoundingPoly {
718 #[serde(default)]
719 vertices: Vec<GoogleVisionVertex>,
720}
721
722#[cfg(feature = "ocr-google")]
723#[derive(Deserialize)]
724struct GoogleVisionVertex {
725 x: Option<f32>,
726 y: Option<f32>,
727}
728
729#[cfg(feature = "ocr-google")]
730#[derive(Deserialize)]
731struct GoogleVisionError {
732 message: Option<String>,
733}
734
735#[cfg(feature = "ocr-google")]
736#[derive(Deserialize)]
737struct GoogleCredentialsFile {
738 #[serde(rename = "type")]
739 kind: Option<String>,
740 client_email: Option<String>,
741 private_key: Option<String>,
742 token_uri: Option<String>,
743 client_id: Option<String>,
744 client_secret: Option<String>,
745 refresh_token: Option<String>,
746}
747
748#[cfg(feature = "ocr-google")]
749#[derive(Serialize)]
750struct GoogleServiceAccountClaims<'a> {
751 iss: &'a str,
752 scope: &'a str,
753 aud: &'a str,
754 exp: i64,
755 iat: i64,
756}
757
758#[cfg(feature = "ocr-google")]
759#[derive(Deserialize)]
760struct GoogleOAuthTokenResponse {
761 access_token: Option<String>,
762 error: Option<String>,
763 error_description: Option<String>,
764}
765
766#[cfg(feature = "ocr-google")]
767fn parse_google_credentials(json: &str) -> Result<GoogleCredentialsFile, OcrError> {
768 serde_json::from_str(json)
769 .map_err(|e| OcrError::RecognitionFailed(format!("parse Google credentials JSON: {e}")))
770}
771
772#[cfg(feature = "ocr-google")]
773fn google_access_token(
774 client: &reqwest::blocking::Client,
775 credentials_json: &str,
776) -> Result<String, OcrError> {
777 let credentials = parse_google_credentials(credentials_json)?;
778 match credentials.kind.as_deref() {
779 Some("service_account") => google_service_account_access_token(client, &credentials),
780 Some("authorized_user") => google_authorized_user_access_token(client, &credentials),
781 Some(other) => Err(OcrError::RecognitionFailed(format!(
782 "unsupported Google credentials type {other}"
783 ))),
784 None if credentials.refresh_token.is_some() => {
785 google_authorized_user_access_token(client, &credentials)
786 }
787 None => Err(OcrError::RecognitionFailed(
788 "Google credentials are missing the `type` field".into(),
789 )),
790 }
791}
792
793#[cfg(feature = "ocr-google")]
794fn google_service_account_access_token(
795 client: &reqwest::blocking::Client,
796 credentials: &GoogleCredentialsFile,
797) -> Result<String, OcrError> {
798 let client_email = credentials.client_email.as_deref().ok_or_else(|| {
799 OcrError::RecognitionFailed("Google service-account JSON is missing `client_email`".into())
800 })?;
801 let private_key_pem = credentials.private_key.as_deref().ok_or_else(|| {
802 OcrError::RecognitionFailed("Google service-account JSON is missing `private_key`".into())
803 })?;
804 let token_uri = credentials
805 .token_uri
806 .as_deref()
807 .unwrap_or(GOOGLE_TOKEN_ENDPOINT);
808
809 let now = OffsetDateTime::now_utc().unix_timestamp();
810 let claims = GoogleServiceAccountClaims {
811 iss: client_email,
812 scope: GOOGLE_OAUTH_SCOPE,
813 aud: token_uri,
814 exp: now + 3600,
815 iat: now,
816 };
817
818 let header_json = serde_json::to_vec(&serde_json::json!({
819 "alg": "RS256",
820 "typ": "JWT",
821 }))
822 .map_err(|e| OcrError::RecognitionFailed(format!("serialize Google JWT header: {e}")))?;
823 let claims_json = serde_json::to_vec(&claims)
824 .map_err(|e| OcrError::RecognitionFailed(format!("serialize Google JWT claims: {e}")))?;
825
826 let signing_input = format!(
827 "{}.{}",
828 google_base64_url(&header_json),
829 google_base64_url(&claims_json)
830 );
831 let private_key = RsaPrivateKey::from_pkcs8_pem(private_key_pem).map_err(|e| {
832 OcrError::RecognitionFailed(format!("parse Google service-account private key: {e}"))
833 })?;
834 let signing_key = SigningKey::<Sha256>::new(private_key);
835 let signature = signing_key.sign(signing_input.as_bytes());
836 let assertion = format!("{signing_input}.{}", google_base64_url(&signature.to_vec()));
837
838 let response = client
839 .post(token_uri)
840 .form(&[
841 ("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"),
842 ("assertion", assertion.as_str()),
843 ])
844 .send()
845 .map_err(|e| {
846 OcrError::RecognitionFailed(format!(
847 "request Google OAuth access token with service account: {e}"
848 ))
849 })?;
850
851 google_token_response_text(response)
852}
853
854#[cfg(feature = "ocr-google")]
855fn google_authorized_user_access_token(
856 client: &reqwest::blocking::Client,
857 credentials: &GoogleCredentialsFile,
858) -> Result<String, OcrError> {
859 let client_id = credentials.client_id.as_deref().ok_or_else(|| {
860 OcrError::RecognitionFailed(
861 "Google application-default credentials are missing `client_id`".into(),
862 )
863 })?;
864 let client_secret = credentials.client_secret.as_deref().ok_or_else(|| {
865 OcrError::RecognitionFailed(
866 "Google application-default credentials are missing `client_secret`".into(),
867 )
868 })?;
869 let refresh_token = credentials.refresh_token.as_deref().ok_or_else(|| {
870 OcrError::RecognitionFailed(
871 "Google application-default credentials are missing `refresh_token`".into(),
872 )
873 })?;
874 let token_uri = credentials
875 .token_uri
876 .as_deref()
877 .unwrap_or(GOOGLE_TOKEN_ENDPOINT);
878
879 let response = client
880 .post(token_uri)
881 .form(&[
882 ("client_id", client_id),
883 ("client_secret", client_secret),
884 ("refresh_token", refresh_token),
885 ("grant_type", "refresh_token"),
886 ])
887 .send()
888 .map_err(|e| {
889 OcrError::RecognitionFailed(format!(
890 "request Google OAuth access token with refresh token: {e}"
891 ))
892 })?;
893
894 google_token_response_text(response)
895}
896
897#[cfg(feature = "ocr-google")]
898fn google_token_response_text(response: reqwest::blocking::Response) -> Result<String, OcrError> {
899 let status = response.status();
900 let body = response.text().map_err(|e| {
901 OcrError::RecognitionFailed(format!("read Google OAuth token response: {e}"))
902 })?;
903
904 if !status.is_success() {
905 return Err(OcrError::RecognitionFailed(format!(
906 "Google OAuth token exchange returned {status}: {body}"
907 )));
908 }
909
910 let token: GoogleOAuthTokenResponse = serde_json::from_str(&body).map_err(|e| {
911 OcrError::RecognitionFailed(format!("parse Google OAuth token response: {e}"))
912 })?;
913
914 if let Some(access_token) = token.access_token {
915 return Ok(access_token);
916 }
917
918 let error = token.error.unwrap_or_else(|| "unknown error".into());
919 let description = token
920 .error_description
921 .unwrap_or_else(|| "no error description".into());
922 Err(OcrError::RecognitionFailed(format!(
923 "Google OAuth token exchange failed: {error}: {description}"
924 )))
925}
926
927#[cfg(feature = "ocr-google")]
928fn google_base64_url(data: &[u8]) -> String {
929 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data)
930}
931
932#[cfg(feature = "ocr-google")]
933fn google_application_default_credentials_path() -> Option<std::path::PathBuf> {
934 #[cfg(target_os = "windows")]
935 {
936 std::env::var_os("APPDATA")
937 .map(std::path::PathBuf::from)
938 .map(|dir| {
939 dir.join("gcloud")
940 .join("application_default_credentials.json")
941 })
942 }
943
944 #[cfg(not(target_os = "windows"))]
945 {
946 home_dir().map(|dir| {
947 dir.join(".config")
948 .join("gcloud")
949 .join("application_default_credentials.json")
950 })
951 }
952}
953
954#[cfg(feature = "ocr-aws")]
957const AWS_TEXTRACT_CONTENT_TYPE: &str = "application/x-amz-json-1.1";
958#[cfg(feature = "ocr-aws")]
959const AWS_TEXTRACT_TARGET: &str = "Textract.DetectDocumentText";
960#[cfg(feature = "ocr-aws")]
961const AWS_TEXTRACT_SERVICE: &str = "textract";
962
963#[cfg(feature = "ocr-aws")]
965pub struct AwsTextractBackend {
966 region: String,
967 access_key: Option<String>,
968 secret_key: Option<String>,
969 session_token: Option<String>,
970 client: reqwest::blocking::Client,
971 endpoint: String,
972}
973
974#[cfg(feature = "ocr-aws")]
975impl AwsTextractBackend {
976 pub fn new(region: &str, access_key: &str, secret_key: &str) -> Self {
978 Self::with_credentials(
979 region,
980 access_key,
981 secret_key,
982 None,
983 format!("https://textract.{region}.amazonaws.com/"),
984 )
985 }
986
987 pub fn from_env() -> Result<Self, OcrError> {
995 let profile_name = std::env::var("AWS_PROFILE").unwrap_or_else(|_| "default".into());
996 let shared = load_aws_profile(&profile_name).unwrap_or_default();
997
998 let region = std::env::var("AWS_REGION")
999 .ok()
1000 .or_else(|| std::env::var("AWS_DEFAULT_REGION").ok())
1001 .or(shared.region);
1002 let access_key = std::env::var("AWS_ACCESS_KEY_ID")
1003 .ok()
1004 .or(shared.access_key);
1005 let secret_key = std::env::var("AWS_SECRET_ACCESS_KEY")
1006 .ok()
1007 .or(shared.secret_key);
1008 let session_token = std::env::var("AWS_SESSION_TOKEN")
1009 .ok()
1010 .or(shared.session_token);
1011
1012 match (region, access_key, secret_key) {
1013 (Some(region), Some(access_key), Some(secret_key)) => Ok(Self::with_credentials(
1014 ®ion,
1015 &access_key,
1016 &secret_key,
1017 session_token.as_deref(),
1018 format!("https://textract.{region}.amazonaws.com/"),
1019 )),
1020 _ => Err(OcrError::NoEngine),
1021 }
1022 }
1023
1024 fn with_credentials(
1025 region: &str,
1026 access_key: &str,
1027 secret_key: &str,
1028 session_token: Option<&str>,
1029 endpoint: String,
1030 ) -> Self {
1031 Self {
1032 region: region.to_string(),
1033 access_key: Some(access_key.to_string()),
1034 secret_key: Some(secret_key.to_string()),
1035 session_token: session_token.map(str::to_string),
1036 client: blocking_http_client(),
1037 endpoint,
1038 }
1039 }
1040
1041 #[cfg(test)]
1042 fn with_endpoint(region: &str, access_key: &str, secret_key: &str, endpoint: &str) -> Self {
1043 Self::with_credentials(region, access_key, secret_key, None, endpoint.to_string())
1044 }
1045}
1046
1047#[cfg(feature = "ocr-aws")]
1048impl OcrBackend for AwsTextractBackend {
1049 fn recognize(&self, image_data: &[u8], width: u32, height: u32) -> Result<OcrResult, OcrError> {
1050 let access_key = self.access_key.as_deref().ok_or(OcrError::NoEngine)?;
1051 let secret_key = self.secret_key.as_deref().ok_or(OcrError::NoEngine)?;
1052 let encoded = rgb_base64_png(image_data, width, height)?;
1053 let body = AwsTextractRequest {
1054 document: AwsTextractDocument { bytes: &encoded },
1055 };
1056 let body_json = serde_json::to_vec(&body).map_err(|e| {
1057 OcrError::RecognitionFailed(format!("serialize AWS Textract request: {e}"))
1058 })?;
1059
1060 let url = reqwest::Url::parse(&self.endpoint).map_err(|e| {
1061 OcrError::RecognitionFailed(format!("parse AWS Textract endpoint: {e}"))
1062 })?;
1063 let host = host_header_value(&url)?;
1064 let canonical_uri = if url.path().is_empty() {
1065 "/"
1066 } else {
1067 url.path()
1068 };
1069 let canonical_query = url.query().unwrap_or("");
1070 let now = OffsetDateTime::now_utc();
1071 let amz_date = now
1072 .format(&format_description!(
1073 "[year][month][day]T[hour][minute][second]Z"
1074 ))
1075 .map_err(|e| OcrError::RecognitionFailed(format!("format AWS SigV4 timestamp: {e}")))?;
1076 let date_stamp = now
1077 .format(&format_description!("[year][month][day]"))
1078 .map_err(|e| {
1079 OcrError::RecognitionFailed(format!("format AWS SigV4 date stamp: {e}"))
1080 })?;
1081 let payload_hash = sha256_hex(&body_json);
1082
1083 let mut headers = vec![
1084 (
1085 "content-type".to_string(),
1086 AWS_TEXTRACT_CONTENT_TYPE.to_string(),
1087 ),
1088 ("host".to_string(), host.clone()),
1089 ("x-amz-date".to_string(), amz_date.clone()),
1090 ("x-amz-target".to_string(), AWS_TEXTRACT_TARGET.to_string()),
1091 ];
1092 if let Some(token) = &self.session_token {
1093 headers.push(("x-amz-security-token".to_string(), token.clone()));
1094 }
1095 headers.sort_by(|left, right| left.0.cmp(&right.0));
1096
1097 let canonical_headers = headers
1098 .iter()
1099 .map(|(name, value)| format!("{name}:{}\n", value.trim()))
1100 .collect::<String>();
1101 let signed_headers = headers
1102 .iter()
1103 .map(|(name, _)| name.as_str())
1104 .collect::<Vec<_>>()
1105 .join(";");
1106 let canonical_request = format!(
1107 "POST\n{canonical_uri}\n{canonical_query}\n{canonical_headers}\n{signed_headers}\n{payload_hash}"
1108 );
1109 let credential_scope = format!(
1110 "{date_stamp}/{}/{AWS_TEXTRACT_SERVICE}/aws4_request",
1111 self.region
1112 );
1113 let string_to_sign = format!(
1114 "AWS4-HMAC-SHA256\n{amz_date}\n{credential_scope}\n{}",
1115 sha256_hex(canonical_request.as_bytes())
1116 );
1117 let signature = aws_sigv4_signature(
1118 secret_key,
1119 &date_stamp,
1120 &self.region,
1121 AWS_TEXTRACT_SERVICE,
1122 &string_to_sign,
1123 )?;
1124 let authorization = format!(
1125 "AWS4-HMAC-SHA256 Credential={access_key}/{credential_scope}, SignedHeaders={signed_headers}, Signature={signature}"
1126 );
1127
1128 let mut request = self
1129 .client
1130 .post(url)
1131 .header(reqwest::header::CONTENT_TYPE, AWS_TEXTRACT_CONTENT_TYPE)
1132 .header("X-Amz-Target", AWS_TEXTRACT_TARGET)
1133 .header("X-Amz-Date", &amz_date)
1134 .header(reqwest::header::HOST, host)
1135 .header(reqwest::header::AUTHORIZATION, authorization);
1136
1137 if let Some(token) = &self.session_token {
1138 request = request.header("X-Amz-Security-Token", token);
1139 }
1140
1141 let response = request.body(body_json).send().map_err(|e| {
1142 OcrError::RecognitionFailed(format!("AWS Textract OCR request failed: {e}"))
1143 })?;
1144
1145 let status = response.status();
1146 let body = response.text().map_err(|e| {
1147 OcrError::RecognitionFailed(format!("read AWS Textract OCR response: {e}"))
1148 })?;
1149
1150 if !status.is_success() {
1151 return Err(OcrError::RecognitionFailed(format!(
1152 "AWS Textract returned {status}: {body}"
1153 )));
1154 }
1155
1156 let parsed: AwsTextractResponse = serde_json::from_str(&body).map_err(|e| {
1157 OcrError::RecognitionFailed(format!("parse AWS Textract OCR response: {e}"))
1158 })?;
1159
1160 let mut words = Vec::new();
1161 let mut lines = Vec::new();
1162 for block in parsed
1163 .blocks
1164 .into_iter()
1165 .filter(|block| block.block_type.as_deref() == Some("LINE"))
1166 {
1167 let text = block.text.unwrap_or_default().trim().to_string();
1168 if text.is_empty() {
1169 continue;
1170 }
1171
1172 lines.push(text.clone());
1173 let bbox = block
1174 .geometry
1175 .and_then(|geometry| geometry.bounding_box)
1176 .map(|bbox| {
1177 [
1178 bbox.left.unwrap_or(0.0) * width as f32,
1179 bbox.top.unwrap_or(0.0) * height as f32,
1180 bbox.width.unwrap_or(0.0) * width as f32,
1181 bbox.height.unwrap_or(0.0) * height as f32,
1182 ]
1183 })
1184 .unwrap_or([0.0, 0.0, 0.0, 0.0]);
1185 let confidence = block.confidence.unwrap_or(100.0) / 100.0;
1186 words.push(OcrWord {
1187 text,
1188 bbox,
1189 confidence,
1190 });
1191 }
1192
1193 let text = lines.join("\n");
1194 let confidence = if text.trim().is_empty() {
1195 0.0
1196 } else {
1197 confidence_from_words(&words, 1.0)
1198 };
1199
1200 Ok(OcrResult {
1201 text,
1202 words,
1203 confidence,
1204 })
1205 }
1206
1207 fn name(&self) -> &str {
1208 "aws-textract"
1209 }
1210}
1211
1212#[cfg(feature = "ocr-aws")]
1213#[derive(Serialize)]
1214struct AwsTextractRequest<'a> {
1215 #[serde(rename = "Document")]
1216 document: AwsTextractDocument<'a>,
1217}
1218
1219#[cfg(feature = "ocr-aws")]
1220#[derive(Serialize)]
1221struct AwsTextractDocument<'a> {
1222 #[serde(rename = "Bytes")]
1223 bytes: &'a str,
1224}
1225
1226#[cfg(feature = "ocr-aws")]
1227#[derive(Deserialize)]
1228struct AwsTextractResponse {
1229 #[serde(rename = "Blocks", default)]
1230 blocks: Vec<AwsTextractBlock>,
1231}
1232
1233#[cfg(feature = "ocr-aws")]
1234#[derive(Deserialize)]
1235struct AwsTextractBlock {
1236 #[serde(rename = "BlockType")]
1237 block_type: Option<String>,
1238 #[serde(rename = "Text")]
1239 text: Option<String>,
1240 #[serde(rename = "Confidence")]
1241 confidence: Option<f32>,
1242 #[serde(rename = "Geometry")]
1243 geometry: Option<AwsTextractGeometry>,
1244}
1245
1246#[cfg(feature = "ocr-aws")]
1247#[derive(Deserialize)]
1248struct AwsTextractGeometry {
1249 #[serde(rename = "BoundingBox")]
1250 bounding_box: Option<AwsTextractBoundingBox>,
1251}
1252
1253#[cfg(feature = "ocr-aws")]
1254#[derive(Deserialize)]
1255struct AwsTextractBoundingBox {
1256 #[serde(rename = "Left")]
1257 left: Option<f32>,
1258 #[serde(rename = "Top")]
1259 top: Option<f32>,
1260 #[serde(rename = "Width")]
1261 width: Option<f32>,
1262 #[serde(rename = "Height")]
1263 height: Option<f32>,
1264}
1265
1266#[cfg(feature = "ocr-aws")]
1267#[derive(Default)]
1268struct AwsResolvedCredentials {
1269 region: Option<String>,
1270 access_key: Option<String>,
1271 secret_key: Option<String>,
1272 session_token: Option<String>,
1273}
1274
1275#[cfg(feature = "ocr-aws")]
1276fn load_aws_profile(profile_name: &str) -> Option<AwsResolvedCredentials> {
1277 let credentials_sections = aws_shared_file("credentials")
1278 .and_then(|path| std::fs::read_to_string(path).ok())
1279 .map(|text| parse_ini_sections(&text))
1280 .unwrap_or_default();
1281 let config_sections = aws_shared_file("config")
1282 .and_then(|path| std::fs::read_to_string(path).ok())
1283 .map(|text| parse_ini_sections(&text))
1284 .unwrap_or_default();
1285
1286 let credentials_section = credentials_sections
1287 .get(profile_name)
1288 .or_else(|| credentials_sections.get(&format!("profile {profile_name}")));
1289 let config_section_name = if profile_name == "default" {
1290 "default".to_string()
1291 } else {
1292 format!("profile {profile_name}")
1293 };
1294 let config_section = config_sections.get(&config_section_name);
1295
1296 Some(AwsResolvedCredentials {
1297 region: config_section.and_then(|section| section.get("region").cloned()),
1298 access_key: credentials_section
1299 .and_then(|section| section.get("aws_access_key_id").cloned()),
1300 secret_key: credentials_section
1301 .and_then(|section| section.get("aws_secret_access_key").cloned()),
1302 session_token: credentials_section
1303 .and_then(|section| section.get("aws_session_token").cloned()),
1304 })
1305}
1306
1307#[cfg(feature = "ocr-aws")]
1308fn aws_shared_file(name: &str) -> Option<std::path::PathBuf> {
1309 #[cfg(target_os = "windows")]
1310 {
1311 home_dir().map(|dir| dir.join(".aws").join(name))
1312 }
1313
1314 #[cfg(not(target_os = "windows"))]
1315 {
1316 home_dir().map(|dir| dir.join(".aws").join(name))
1317 }
1318}
1319
1320#[cfg(feature = "ocr-aws")]
1321fn parse_ini_sections(
1322 text: &str,
1323) -> std::collections::HashMap<String, std::collections::HashMap<String, String>> {
1324 let mut sections: std::collections::HashMap<String, std::collections::HashMap<String, String>> =
1325 std::collections::HashMap::new();
1326 let mut current = String::new();
1327
1328 for raw_line in text.lines() {
1329 let line = raw_line.trim();
1330 if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
1331 continue;
1332 }
1333
1334 if let Some(section) = line
1335 .strip_prefix('[')
1336 .and_then(|line| line.strip_suffix(']'))
1337 {
1338 current = section.trim().to_string();
1339 sections.entry(current.clone()).or_default();
1340 continue;
1341 }
1342
1343 if let Some((key, value)) = line.split_once('=') {
1344 sections
1345 .entry(current.clone())
1346 .or_default()
1347 .insert(key.trim().to_ascii_lowercase(), value.trim().to_string());
1348 }
1349 }
1350
1351 sections
1352}
1353
1354#[cfg(feature = "ocr-aws")]
1355fn host_header_value(url: &reqwest::Url) -> Result<String, OcrError> {
1356 let host = url.host_str().ok_or_else(|| {
1357 OcrError::RecognitionFailed("AWS Textract endpoint is missing a host".into())
1358 })?;
1359 Ok(match url.port() {
1360 Some(port) => format!("{host}:{port}"),
1361 None => host.to_string(),
1362 })
1363}
1364
1365#[cfg(feature = "ocr-aws")]
1366fn aws_sigv4_signature(
1367 secret_key: &str,
1368 date_stamp: &str,
1369 region: &str,
1370 service: &str,
1371 string_to_sign: &str,
1372) -> Result<String, OcrError> {
1373 let k_date = hmac_sha256(format!("AWS4{secret_key}").as_bytes(), date_stamp)?;
1374 let k_region = hmac_sha256(&k_date, region)?;
1375 let k_service = hmac_sha256(&k_region, service)?;
1376 let k_signing = hmac_sha256(&k_service, "aws4_request")?;
1377 let signature = hmac_sha256(&k_signing, string_to_sign)?;
1378 Ok(hex_encode(&signature))
1379}
1380
1381#[cfg(feature = "ocr-aws")]
1382fn hmac_sha256(key: &[u8], data: &str) -> Result<Vec<u8>, OcrError> {
1383 type HmacSha256 = Hmac<Sha256>;
1384
1385 let mut mac = HmacSha256::new_from_slice(key)
1386 .map_err(|e| OcrError::RecognitionFailed(format!("build AWS SigV4 HMAC: {e}")))?;
1387 mac.update(data.as_bytes());
1388 Ok(mac.finalize().into_bytes().to_vec())
1389}
1390
1391#[cfg(feature = "ocr-aws")]
1392fn sha256_hex(data: &[u8]) -> String {
1393 hex_encode(&Sha256::digest(data))
1394}
1395
1396#[cfg(feature = "ocr-aws")]
1397fn hex_encode(bytes: &[u8]) -> String {
1398 let mut output = String::with_capacity(bytes.len() * 2);
1399 for byte in bytes {
1400 use std::fmt::Write as _;
1401 let _ = write!(&mut output, "{byte:02x}");
1402 }
1403 output
1404}
1405
1406#[cfg(feature = "ocr-azure")]
1409const AZURE_DOC_INTELLIGENCE_API_VERSION: &str = "2024-11-30";
1410
1411#[cfg(feature = "ocr-azure")]
1413pub struct AzureDocIntelBackend {
1414 endpoint: String,
1415 api_key: String,
1416 client: reqwest::blocking::Client,
1417}
1418
1419#[cfg(feature = "ocr-azure")]
1420impl AzureDocIntelBackend {
1421 pub fn new(endpoint: &str, api_key: &str) -> Self {
1423 Self {
1424 endpoint: endpoint.trim_end_matches('/').to_string(),
1425 api_key: api_key.to_string(),
1426 client: blocking_http_client(),
1427 }
1428 }
1429
1430 pub fn from_env() -> Result<Self, OcrError> {
1436 let endpoint = std::env::var("AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT")
1437 .map_err(|_| OcrError::NoEngine)?;
1438 let api_key =
1439 std::env::var("AZURE_DOCUMENT_INTELLIGENCE_KEY").map_err(|_| OcrError::NoEngine)?;
1440 Ok(Self::new(&endpoint, &api_key))
1441 }
1442
1443 fn analyze_endpoint(&self) -> String {
1444 let api_version = std::env::var("AZURE_DOCUMENT_INTELLIGENCE_API_VERSION")
1445 .unwrap_or_else(|_| AZURE_DOC_INTELLIGENCE_API_VERSION.to_string());
1446 format!(
1447 "{}/documentintelligence/documentModels/prebuilt-read:analyze?api-version={api_version}",
1448 self.endpoint
1449 )
1450 }
1451
1452 fn poll_interval() -> Duration {
1453 std::env::var("AZURE_DOCUMENT_INTELLIGENCE_POLL_INTERVAL_MS")
1454 .ok()
1455 .and_then(|value| value.parse::<u64>().ok())
1456 .map(Duration::from_millis)
1457 .unwrap_or_else(|| Duration::from_millis(250))
1458 }
1459
1460 fn max_polls() -> usize {
1461 std::env::var("AZURE_DOCUMENT_INTELLIGENCE_MAX_POLLS")
1462 .ok()
1463 .and_then(|value| value.parse::<usize>().ok())
1464 .unwrap_or(120)
1465 }
1466
1467 fn poll_operation(&self, operation_location: &str) -> Result<OcrResult, OcrError> {
1468 for attempt in 0..Self::max_polls() {
1469 let response = self
1470 .client
1471 .get(operation_location)
1472 .header("Ocp-Apim-Subscription-Key", &self.api_key)
1473 .send()
1474 .map_err(|e| {
1475 OcrError::RecognitionFailed(format!(
1476 "Azure Document Intelligence poll request failed: {e}"
1477 ))
1478 })?;
1479
1480 let status = response.status();
1481 let body = response.text().map_err(|e| {
1482 OcrError::RecognitionFailed(format!(
1483 "read Azure Document Intelligence poll response: {e}"
1484 ))
1485 })?;
1486
1487 if !status.is_success() {
1488 return Err(OcrError::RecognitionFailed(format!(
1489 "Azure Document Intelligence poll returned {status}: {body}"
1490 )));
1491 }
1492
1493 let operation: AzureAnalyzeOperation = serde_json::from_str(&body).map_err(|e| {
1494 OcrError::RecognitionFailed(format!(
1495 "parse Azure Document Intelligence poll response: {e}"
1496 ))
1497 })?;
1498
1499 if operation.status.eq_ignore_ascii_case("succeeded") {
1500 return azure_operation_to_result(operation);
1501 }
1502
1503 if operation.status.eq_ignore_ascii_case("failed")
1504 || operation.status.eq_ignore_ascii_case("cancelled")
1505 {
1506 let message = operation
1507 .error
1508 .and_then(|error| error.message)
1509 .unwrap_or_else(|| format!("Azure operation status {}", operation.status));
1510 return Err(OcrError::RecognitionFailed(format!(
1511 "Azure Document Intelligence OCR failed: {message}"
1512 )));
1513 }
1514
1515 if attempt + 1 < Self::max_polls() {
1516 std::thread::sleep(Self::poll_interval());
1517 }
1518 }
1519
1520 Err(OcrError::RecognitionFailed(
1521 "timed out polling Azure Document Intelligence analyze operation".into(),
1522 ))
1523 }
1524}
1525
1526#[cfg(feature = "ocr-azure")]
1527impl OcrBackend for AzureDocIntelBackend {
1528 fn recognize(&self, image_data: &[u8], width: u32, height: u32) -> Result<OcrResult, OcrError> {
1529 let png_bytes = rgb_png_bytes(image_data, width, height)?;
1530 let response = self
1531 .client
1532 .post(self.analyze_endpoint())
1533 .header("Ocp-Apim-Subscription-Key", &self.api_key)
1534 .header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
1535 .body(png_bytes)
1536 .send()
1537 .map_err(|e| {
1538 OcrError::RecognitionFailed(format!(
1539 "Azure Document Intelligence analyze request failed: {e}"
1540 ))
1541 })?;
1542
1543 let status = response.status();
1544 let operation_location = response
1545 .headers()
1546 .get("operation-location")
1547 .and_then(|value| value.to_str().ok())
1548 .map(str::to_string);
1549 let body = response.text().map_err(|e| {
1550 OcrError::RecognitionFailed(format!(
1551 "read Azure Document Intelligence analyze response: {e}"
1552 ))
1553 })?;
1554
1555 if !status.is_success() {
1556 return Err(OcrError::RecognitionFailed(format!(
1557 "Azure Document Intelligence returned {status}: {body}"
1558 )));
1559 }
1560
1561 if let Some(operation_location) = operation_location {
1562 return self.poll_operation(&operation_location);
1563 }
1564
1565 let operation: AzureAnalyzeOperation = serde_json::from_str(&body).map_err(|e| {
1566 OcrError::RecognitionFailed(format!(
1567 "parse Azure Document Intelligence analyze response: {e}"
1568 ))
1569 })?;
1570 azure_operation_to_result(operation)
1571 }
1572
1573 fn name(&self) -> &str {
1574 "azure-doc-intel"
1575 }
1576}
1577
1578#[cfg(feature = "ocr-azure")]
1579#[derive(Deserialize)]
1580struct AzureAnalyzeOperation {
1581 #[serde(default)]
1582 status: String,
1583 #[serde(rename = "analyzeResult")]
1584 analyze_result: Option<AzureAnalyzeResult>,
1585 error: Option<AzureAnalyzeError>,
1586}
1587
1588#[cfg(feature = "ocr-azure")]
1589#[derive(Deserialize)]
1590struct AzureAnalyzeError {
1591 message: Option<String>,
1592}
1593
1594#[cfg(feature = "ocr-azure")]
1595#[derive(Deserialize)]
1596struct AzureAnalyzeResult {
1597 content: Option<String>,
1598 #[serde(default)]
1599 pages: Vec<AzureAnalyzePage>,
1600}
1601
1602#[cfg(feature = "ocr-azure")]
1603#[derive(Deserialize)]
1604struct AzureAnalyzePage {
1605 #[serde(default)]
1606 words: Vec<AzureAnalyzeWord>,
1607}
1608
1609#[cfg(feature = "ocr-azure")]
1610#[derive(Deserialize)]
1611struct AzureAnalyzeWord {
1612 content: String,
1613 confidence: Option<f32>,
1614 #[serde(default, alias = "boundingPolygon")]
1615 polygon: Vec<f32>,
1616}
1617
1618#[cfg(feature = "ocr-azure")]
1619fn azure_operation_to_result(operation: AzureAnalyzeOperation) -> Result<OcrResult, OcrError> {
1620 let analyze_result = operation.analyze_result.ok_or_else(|| {
1621 OcrError::RecognitionFailed(format!(
1622 "Azure Document Intelligence operation ended with status {} but no analyze result",
1623 operation.status
1624 ))
1625 })?;
1626
1627 let mut words = Vec::new();
1628 for page in analyze_result.pages {
1629 for word in page.words {
1630 let points = word
1631 .polygon
1632 .chunks_exact(2)
1633 .map(|pair| (pair[0], pair[1]))
1634 .collect::<Vec<_>>();
1635 words.push(OcrWord {
1636 text: word.content,
1637 bbox: bbox_from_points(&points),
1638 confidence: word.confidence.unwrap_or(1.0),
1639 });
1640 }
1641 }
1642
1643 let text = analyze_result.content.unwrap_or_else(|| {
1644 words
1645 .iter()
1646 .map(|word| word.text.as_str())
1647 .collect::<Vec<_>>()
1648 .join(" ")
1649 });
1650 let confidence = if text.trim().is_empty() {
1651 0.0
1652 } else {
1653 confidence_from_words(&words, 1.0)
1654 };
1655
1656 Ok(OcrResult {
1657 text,
1658 words,
1659 confidence,
1660 })
1661}
1662
1663#[cfg(feature = "ocr-onnx")]
1667pub struct PaddleOnnxBackend {
1668 engine: pdf_ocr::PaddleOcrEngine,
1669}
1670
1671#[cfg(feature = "ocr-onnx")]
1672impl PaddleOnnxBackend {
1673 pub fn new() -> Result<Self, OcrError> {
1675 let engine = pdf_ocr::PaddleOcrEngine::new()
1676 .map_err(|e| OcrError::RecognitionFailed(format!("init PaddleOCR: {e}")))?;
1677 Ok(Self { engine })
1678 }
1679
1680 pub fn from_env() -> Result<Self, OcrError> {
1685 Self::new()
1686 }
1687}
1688
1689#[cfg(feature = "ocr-onnx")]
1690impl OcrBackend for PaddleOnnxBackend {
1691 fn recognize(&self, image_data: &[u8], width: u32, height: u32) -> Result<OcrResult, OcrError> {
1692 use pdf_ocr::OcrEngine;
1693
1694 let result = self
1695 .engine
1696 .recognize(image_data, width, height, 300)
1697 .map_err(|e| OcrError::RecognitionFailed(format!("PaddleOCR recognize: {e}")))?;
1698 let text = result.full_text();
1699 let words = result
1700 .words
1701 .into_iter()
1702 .map(|word| OcrWord {
1703 text: word.text,
1704 bbox: [
1705 word.bbox_px[0] as f32,
1706 word.bbox_px[1] as f32,
1707 word.bbox_px[2].saturating_sub(word.bbox_px[0]) as f32,
1708 word.bbox_px[3].saturating_sub(word.bbox_px[1]) as f32,
1709 ],
1710 confidence: word.confidence,
1711 })
1712 .collect();
1713
1714 Ok(OcrResult {
1715 text,
1716 words,
1717 confidence: result.confidence,
1718 })
1719 }
1720
1721 fn name(&self) -> &str {
1722 "paddle-onnx"
1723 }
1724}
1725
1726#[cfg(feature = "ocr")]
1749pub struct OcrsBackend {
1750 engine: ocrs::OcrEngine,
1751}
1752
1753#[cfg(feature = "ocr")]
1754impl OcrsBackend {
1755 pub fn from_bytes(detection: &[u8], recognition: &[u8]) -> Result<Self, OcrError> {
1760 let det = rten::Model::load(detection.to_vec()).map_err(|_| OcrError::NoEngine)?;
1761 let rec = rten::Model::load(recognition.to_vec()).map_err(|_| OcrError::NoEngine)?;
1762 Self::build(det, rec)
1763 }
1764
1765 #[cfg(not(target_arch = "wasm32"))]
1769 pub fn from_files(
1770 detection_path: impl AsRef<std::path::Path>,
1771 recognition_path: impl AsRef<std::path::Path>,
1772 ) -> Result<Self, OcrError> {
1773 let det = rten::Model::load_file(detection_path).map_err(|_| OcrError::NoEngine)?;
1774 let rec = rten::Model::load_file(recognition_path).map_err(|_| OcrError::NoEngine)?;
1775 Self::build(det, rec)
1776 }
1777
1778 #[cfg(not(target_arch = "wasm32"))]
1786 pub fn try_default() -> Result<Self, OcrError> {
1787 let det_path: std::path::PathBuf = std::env::var("OCRS_DETECTION_MODEL")
1788 .ok()
1789 .map(std::path::PathBuf::from)
1790 .or_else(|| default_model_path("text-detection.rten"))
1791 .ok_or(OcrError::NoEngine)?;
1792 let rec_path: std::path::PathBuf = std::env::var("OCRS_RECOGNITION_MODEL")
1793 .ok()
1794 .map(std::path::PathBuf::from)
1795 .or_else(|| default_model_path("text-recognition.rten"))
1796 .ok_or(OcrError::NoEngine)?;
1797 if !det_path.exists() || !rec_path.exists() {
1798 return Err(OcrError::NoEngine);
1799 }
1800 Self::from_files(det_path, rec_path)
1801 }
1802
1803 fn build(detection: rten::Model, recognition: rten::Model) -> Result<Self, OcrError> {
1804 let engine = ocrs::OcrEngine::new(ocrs::OcrEngineParams {
1805 detection_model: Some(detection),
1806 recognition_model: Some(recognition),
1807 ..Default::default()
1808 })
1809 .map_err(|_| OcrError::NoEngine)?;
1810 Ok(Self { engine })
1811 }
1812}
1813
1814#[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
1817fn default_model_path(filename: &str) -> Option<std::path::PathBuf> {
1818 dirs_sys::home_dir().map(|h: std::path::PathBuf| h.join(".cache").join("ocrs").join(filename))
1819}
1820
1821#[cfg(feature = "ocr")]
1822impl OcrBackend for OcrsBackend {
1823 fn recognize(&self, image_data: &[u8], width: u32, height: u32) -> Result<OcrResult, OcrError> {
1824 let image_source = ocrs::ImageSource::from_bytes(image_data, (width, height))
1825 .map_err(|e| OcrError::ImageError(e.to_string()))?;
1826
1827 let input = self
1828 .engine
1829 .prepare_input(image_source)
1830 .map_err(|e| OcrError::RecognitionFailed(e.to_string()))?;
1831
1832 let text = self
1833 .engine
1834 .get_text(&input)
1835 .map_err(|e| OcrError::RecognitionFailed(e.to_string()))?;
1836
1837 let words = text
1838 .split_whitespace()
1839 .map(|w| OcrWord {
1840 text: w.to_string(),
1841 bbox: [0.0, 0.0, 0.0, 0.0],
1842 confidence: 1.0,
1843 })
1844 .collect::<Vec<_>>();
1845
1846 let confidence = if text.is_empty() { 0.0 } else { 1.0 };
1847
1848 Ok(OcrResult {
1849 text,
1850 words,
1851 confidence,
1852 })
1853 }
1854
1855 fn name(&self) -> &str {
1856 "ocrs"
1857 }
1858}
1859
1860#[cfg(not(target_arch = "wasm32"))]
1872pub fn best_available_backend() -> Result<Box<dyn OcrBackend>, OcrError> {
1873 #[cfg(feature = "ocr-mistral")]
1874 if let Ok(backend) = MistralOcrBackend::from_env() {
1875 return Ok(Box::new(backend));
1876 }
1877
1878 #[cfg(feature = "ocr-google")]
1879 if let Ok(backend) = GoogleVisionBackend::from_env() {
1880 return Ok(Box::new(backend));
1881 }
1882
1883 #[cfg(feature = "ocr-azure")]
1884 if let Ok(backend) = AzureDocIntelBackend::from_env() {
1885 return Ok(Box::new(backend));
1886 }
1887
1888 #[cfg(feature = "ocr-aws")]
1889 if let Ok(backend) = AwsTextractBackend::from_env() {
1890 return Ok(Box::new(backend));
1891 }
1892
1893 #[cfg(feature = "ocr-onnx")]
1894 if let Ok(backend) = PaddleOnnxBackend::from_env() {
1895 return Ok(Box::new(backend));
1896 }
1897
1898 #[cfg(feature = "ocr")]
1899 if let Ok(backend) = OcrsBackend::try_default() {
1900 return Ok(Box::new(backend));
1901 }
1902
1903 Err(OcrError::NoEngine)
1904}
1905
1906#[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
1912pub fn ocr_page_default(image_data: &[u8], width: u32, height: u32) -> Result<OcrResult, OcrError> {
1913 let backend = OcrsBackend::try_default()?;
1914 backend.recognize(image_data, width, height)
1915}
1916
1917#[cfg(all(
1918 test,
1919 not(target_arch = "wasm32"),
1920 any(
1921 feature = "ocr-mistral",
1922 feature = "ocr-google",
1923 feature = "ocr-aws",
1924 feature = "ocr-azure"
1925 )
1926))]
1927mod tests {
1928 use super::*;
1929
1930 use std::collections::HashMap;
1931 use std::ffi::OsString;
1932 use std::io::{Read, Write};
1933 use std::net::{TcpListener, TcpStream};
1934 use std::sync::{Mutex, OnceLock};
1935 use std::thread;
1936
1937 fn env_lock() -> &'static Mutex<()> {
1941 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1942 LOCK.get_or_init(|| Mutex::new(()))
1943 }
1944
1945 struct ScopedEnv {
1946 saved: Vec<(String, Option<OsString>)>,
1947 }
1948
1949 impl ScopedEnv {
1950 fn set(changes: &[(&str, Option<&str>)]) -> Self {
1951 let saved = changes
1952 .iter()
1953 .map(|(key, _)| ((*key).to_string(), std::env::var_os(key)))
1954 .collect::<Vec<_>>();
1955
1956 for (key, value) in changes {
1957 match value {
1958 Some(value) => std::env::set_var(key, value),
1959 None => std::env::remove_var(key),
1960 }
1961 }
1962
1963 Self { saved }
1964 }
1965 }
1966
1967 impl Drop for ScopedEnv {
1968 fn drop(&mut self) {
1969 for (key, value) in self.saved.drain(..) {
1970 match value {
1971 Some(value) => std::env::set_var(&key, value),
1972 None => std::env::remove_var(&key),
1973 }
1974 }
1975 }
1976 }
1977
1978 struct MockServer {
1979 base_url: String,
1980 join_handle: Option<thread::JoinHandle<()>>,
1981 }
1982
1983 impl MockServer {
1984 fn start<F>(requests: usize, handler: F) -> Self
1985 where
1986 F: Fn(MockRequest, usize, &str) -> MockResponse + Send + 'static,
1987 {
1988 let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock server");
1989 let address = listener.local_addr().expect("mock server address");
1990 let base_url = format!("http://{address}");
1991 let handler_base_url = base_url.clone();
1992
1993 let join_handle = thread::spawn(move || {
1994 for index in 0..requests {
1995 let (mut stream, _) = listener.accept().expect("accept mock request");
1996 let request = read_http_request(&mut stream);
1997 let response = handler(request, index, &handler_base_url);
1998 write_http_response(&mut stream, response);
1999 }
2000 });
2001
2002 Self {
2003 base_url,
2004 join_handle: Some(join_handle),
2005 }
2006 }
2007
2008 fn url(&self, path: &str) -> String {
2009 format!("{}{}", self.base_url, path)
2010 }
2011
2012 fn finish(mut self) {
2013 if let Some(join_handle) = self.join_handle.take() {
2014 join_handle.join().expect("mock server thread");
2015 }
2016 }
2017 }
2018
2019 #[derive(Debug)]
2020 struct MockRequest {
2021 method: String,
2022 path: String,
2023 headers: HashMap<String, String>,
2024 body: Vec<u8>,
2025 }
2026
2027 struct MockResponse {
2028 status_code: u16,
2029 headers: Vec<(String, String)>,
2030 body: Vec<u8>,
2031 }
2032
2033 impl MockResponse {
2034 fn json(status_code: u16, body: &str) -> Self {
2035 Self {
2036 status_code,
2037 headers: vec![("Content-Type".into(), "application/json".into())],
2038 body: body.as_bytes().to_vec(),
2039 }
2040 }
2041
2042 fn empty(status_code: u16, headers: &[(&str, &str)]) -> Self {
2043 Self {
2044 status_code,
2045 headers: headers
2046 .iter()
2047 .map(|(key, value)| ((*key).to_string(), (*value).to_string()))
2048 .collect(),
2049 body: Vec::new(),
2050 }
2051 }
2052 }
2053
2054 fn read_http_request(stream: &mut TcpStream) -> MockRequest {
2055 let mut buffer = Vec::new();
2056 let mut chunk = [0u8; 4096];
2057 let header_end = loop {
2058 let read = stream.read(&mut chunk).expect("read mock request");
2059 assert!(read > 0, "mock request closed before headers completed");
2060 buffer.extend_from_slice(&chunk[..read]);
2061
2062 if let Some(index) = find_bytes(&buffer, b"\r\n\r\n") {
2063 break index + 4;
2064 }
2065 };
2066
2067 let header_text = String::from_utf8_lossy(&buffer[..header_end]);
2068 let mut lines = header_text.split("\r\n").filter(|line| !line.is_empty());
2069 let request_line = lines.next().expect("request line");
2070 let mut request_parts = request_line.split_whitespace();
2071 let method = request_parts.next().expect("request method").to_string();
2072 let path = request_parts.next().expect("request path").to_string();
2073
2074 let mut headers = HashMap::new();
2075 let mut content_length = 0usize;
2076 for line in lines {
2077 if let Some((name, value)) = line.split_once(':') {
2078 let key = name.trim().to_ascii_lowercase();
2079 let value = value.trim().to_string();
2080 if key == "content-length" {
2081 content_length = value.parse::<usize>().expect("content-length");
2082 }
2083 headers.insert(key, value);
2084 }
2085 }
2086
2087 let mut body = buffer[header_end..].to_vec();
2088 while body.len() < content_length {
2089 let read = stream.read(&mut chunk).expect("read mock request body");
2090 assert!(read > 0, "mock request closed before body completed");
2091 body.extend_from_slice(&chunk[..read]);
2092 }
2093
2094 MockRequest {
2095 method,
2096 path,
2097 headers,
2098 body,
2099 }
2100 }
2101
2102 fn write_http_response(stream: &mut TcpStream, response: MockResponse) {
2103 let reason = match response.status_code {
2104 200 => "OK",
2105 202 => "Accepted",
2106 400 => "Bad Request",
2107 401 => "Unauthorized",
2108 403 => "Forbidden",
2109 404 => "Not Found",
2110 _ => "OK",
2111 };
2112 let mut head = format!(
2113 "HTTP/1.1 {} {}\r\nContent-Length: {}\r\nConnection: close\r\n",
2114 response.status_code,
2115 reason,
2116 response.body.len()
2117 );
2118 for (name, value) in response.headers {
2119 head.push_str(&format!("{name}: {value}\r\n"));
2120 }
2121 head.push_str("\r\n");
2122
2123 stream
2124 .write_all(head.as_bytes())
2125 .expect("write mock response head");
2126 stream
2127 .write_all(&response.body)
2128 .expect("write mock response body");
2129 }
2130
2131 fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
2132 haystack
2133 .windows(needle.len())
2134 .position(|window| window == needle)
2135 }
2136
2137 #[cfg(feature = "ocr-mistral")]
2138 #[test]
2139 fn ocr_mistral_name_is_stable() {
2140 let backend = MistralOcrBackend::new("test-key");
2141 assert_eq!(backend.name(), "mistral");
2142 }
2143
2144 #[cfg(feature = "ocr-mistral")]
2145 #[test]
2146 fn ocr_mistral_from_env_reads_api_key() {
2147 let _env = env_lock().lock().expect("env lock");
2148 let _scoped = ScopedEnv::set(&[("MISTRAL_API_KEY", Some("test-key"))]);
2149
2150 let backend = MistralOcrBackend::from_env().expect("Mistral backend from env");
2151 assert_eq!(backend.name(), "mistral");
2152 }
2153
2154 #[cfg(feature = "ocr-mistral")]
2155 #[test]
2156 fn ocr_mistral_encodes_rgb_image_as_png_data_url() {
2157 let rgb = vec![
2158 255, 255, 255, 0, 0, 0, 0, 0, 0, 255, 255, 255,
2160 ];
2161 let data_url = rgb_data_url(&rgb, 2, 2).expect("PNG data URL");
2162 assert!(data_url.starts_with("data:image/png;base64,"));
2163 assert!(data_url.len() > "data:image/png;base64,".len());
2164 }
2165
2166 #[cfg(feature = "ocr-mistral")]
2167 #[test]
2168 fn ocr_mistral_collects_markdown_pages() {
2169 let response = MistralOcrResponse {
2170 pages: vec![
2171 MistralOcrPage {
2172 markdown: Some(" Hello ".into()),
2173 },
2174 MistralOcrPage {
2175 markdown: Some("World".into()),
2176 },
2177 MistralOcrPage { markdown: None },
2178 ],
2179 };
2180
2181 assert_eq!(mistral_markdown_text(&response), "Hello\n\nWorld");
2182 }
2183
2184 #[cfg(feature = "ocr-google")]
2185 #[test]
2186 fn ocr_google_name_is_stable() {
2187 let backend = GoogleVisionBackend::from_api_key("test-key");
2188 assert_eq!(backend.name(), "google-vision");
2189 }
2190
2191 #[cfg(feature = "ocr-google")]
2192 #[test]
2193 fn ocr_google_from_env_prefers_api_key() {
2194 let _env = env_lock().lock().expect("env lock");
2195 let temp_path = std::env::temp_dir().join("codex-google-creds.json");
2196 let temp_path_string = temp_path.to_string_lossy().into_owned();
2197 std::fs::write(
2198 &temp_path,
2199 r#"{"type":"service_account","client_email":"test@example.com","private_key":"missing"}"#,
2200 )
2201 .expect("write google credentials fixture");
2202 let _scoped = ScopedEnv::set(&[
2203 ("GOOGLE_VISION_API_KEY", Some("test-key")),
2204 (
2205 "GOOGLE_APPLICATION_CREDENTIALS",
2206 Some(temp_path_string.as_str()),
2207 ),
2208 ("GOOGLE_CLOUD_PROJECT", None),
2209 ]);
2210
2211 let backend = GoogleVisionBackend::from_env().expect("Google backend from env");
2212 assert_eq!(backend.name(), "google-vision");
2213 }
2214
2215 #[cfg(feature = "ocr-google")]
2216 #[test]
2217 fn ocr_google_mock_request_uses_api_key_and_parses_words() {
2218 let server = MockServer::start(1, |request, _, _| {
2219 assert_eq!(request.method, "POST");
2220 assert!(request.path.starts_with("/v1/images:annotate?key=test-key"));
2221 assert_eq!(
2222 request.headers.get("content-type").map(String::as_str),
2223 Some("application/json")
2224 );
2225
2226 let body: serde_json::Value =
2227 serde_json::from_slice(&request.body).expect("google request JSON");
2228 assert_eq!(
2229 body["requests"][0]["features"][0]["type"].as_str(),
2230 Some("TEXT_DETECTION")
2231 );
2232
2233 let content = body["requests"][0]["image"]["content"]
2234 .as_str()
2235 .expect("google image content");
2236 let png = base64::engine::general_purpose::STANDARD
2237 .decode(content)
2238 .expect("decode google image");
2239 assert!(png.starts_with(&[0x89, b'P', b'N', b'G']));
2240
2241 MockResponse::json(
2242 200,
2243 r#"{
2244 "responses": [{
2245 "fullTextAnnotation": { "text": "Hello World" },
2246 "textAnnotations": [
2247 { "description": "Hello World" },
2248 {
2249 "description": "Hello",
2250 "boundingPoly": {
2251 "vertices": [
2252 { "x": 1, "y": 2 },
2253 { "x": 41, "y": 2 },
2254 { "x": 41, "y": 14 },
2255 { "x": 1, "y": 14 }
2256 ]
2257 }
2258 },
2259 {
2260 "description": "World",
2261 "boundingPoly": {
2262 "vertices": [
2263 { "x": 50, "y": 2 },
2264 { "x": 92, "y": 2 },
2265 { "x": 92, "y": 14 },
2266 { "x": 50, "y": 14 }
2267 ]
2268 }
2269 }
2270 ]
2271 }]
2272 }"#,
2273 )
2274 });
2275
2276 let backend = GoogleVisionBackend::with_endpoint_api_key(
2277 "test-key",
2278 &server.url("/v1/images:annotate"),
2279 );
2280 let (pixels, width, height) = test_text_image("TEST 123");
2281 let result = backend
2282 .recognize(&pixels, width, height)
2283 .expect("Google OCR");
2284
2285 assert_eq!(result.text, "Hello World");
2286 assert_eq!(result.words.len(), 2);
2287 assert_eq!(result.words[0].text, "Hello");
2288 assert_eq!(result.words[0].bbox, [1.0, 2.0, 40.0, 12.0]);
2289 assert_eq!(result.words[1].text, "World");
2290
2291 server.finish();
2292 }
2293
2294 #[cfg(feature = "ocr-google")]
2295 #[test]
2296 fn ocr_google_service_account_flow_uses_jwt_exchange() {
2297 let server = MockServer::start(2, |request, index, _| match index {
2298 0 => {
2299 assert_eq!(request.method, "POST");
2300 assert_eq!(request.path, "/token");
2301 let body = String::from_utf8(request.body).expect("token body");
2302 assert!(body
2303 .contains("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer"));
2304 assert!(body.contains("assertion="));
2305
2306 MockResponse::json(200, r#"{"access_token":"ya29.test-token"}"#)
2307 }
2308 1 => {
2309 assert_eq!(request.method, "POST");
2310 assert_eq!(request.path, "/v1/images:annotate");
2311 assert_eq!(
2312 request.headers.get("authorization").map(String::as_str),
2313 Some("Bearer ya29.test-token")
2314 );
2315
2316 MockResponse::json(
2317 200,
2318 r#"{
2319 "responses": [{
2320 "fullTextAnnotation": { "text": "Service Account" },
2321 "textAnnotations": [
2322 { "description": "Service Account" },
2323 {
2324 "description": "Service",
2325 "boundingPoly": {
2326 "vertices": [
2327 { "x": 0, "y": 0 },
2328 { "x": 60, "y": 0 },
2329 { "x": 60, "y": 10 },
2330 { "x": 0, "y": 10 }
2331 ]
2332 }
2333 }
2334 ]
2335 }]
2336 }"#,
2337 )
2338 }
2339 _ => unreachable!(),
2340 });
2341
2342 let credentials_json = format!(
2343 r#"{{
2344 "type": "service_account",
2345 "client_email": "test@example.com",
2346 "private_key": {private_key:?},
2347 "token_uri": {token_uri:?}
2348 }}"#,
2349 private_key = concat!(
2353 "-----BEGIN ",
2354 "PRIVATE KEY",
2355 "-----\n",
2356 "TEST_PLACEHOLDER_NOT_A_REAL_KEY\n",
2357 "-----END ",
2358 "PRIVATE KEY",
2359 "-----\n",
2360 ),
2361 token_uri = server.url("/token"),
2362 );
2363
2364 let backend = GoogleVisionBackend::with_endpoint_credentials(
2365 &credentials_json,
2366 &server.url("/v1/images:annotate"),
2367 );
2368 let (pixels, width, height) = test_text_image("TEST");
2369 let result = backend
2370 .recognize(&pixels, width, height)
2371 .expect("Google OCR via service account");
2372
2373 assert_eq!(result.text, "Service Account");
2374 assert_eq!(result.words.len(), 1);
2375 assert_eq!(result.words[0].text, "Service");
2376
2377 server.finish();
2378 }
2379
2380 #[cfg(feature = "ocr-aws")]
2381 #[test]
2382 fn ocr_aws_name_is_stable() {
2383 let backend = AwsTextractBackend::new("eu-west-1", "access", "secret");
2384 assert_eq!(backend.name(), "aws-textract");
2385 }
2386
2387 #[cfg(feature = "ocr-aws")]
2388 #[test]
2389 fn ocr_aws_from_env_reads_region_and_keys() {
2390 let _env = env_lock().lock().expect("env lock");
2391 let _scoped = ScopedEnv::set(&[
2392 ("AWS_REGION", Some("eu-west-1")),
2393 ("AWS_DEFAULT_REGION", None),
2394 ("AWS_ACCESS_KEY_ID", Some("access")),
2395 ("AWS_SECRET_ACCESS_KEY", Some("secret")),
2396 ("AWS_SESSION_TOKEN", None),
2397 ("AWS_PROFILE", None),
2398 ]);
2399
2400 let backend = AwsTextractBackend::from_env().expect("AWS backend from env");
2401 assert_eq!(backend.name(), "aws-textract");
2402 }
2403
2404 #[cfg(feature = "ocr-aws")]
2405 #[test]
2406 fn ocr_aws_mock_request_signs_sigv4_and_parses_lines() {
2407 let server = MockServer::start(1, |request, _, _| {
2408 assert_eq!(request.method, "POST");
2409 assert_eq!(request.path, "/");
2410 assert_eq!(
2411 request.headers.get("content-type").map(String::as_str),
2412 Some("application/x-amz-json-1.1")
2413 );
2414 assert_eq!(
2415 request.headers.get("x-amz-target").map(String::as_str),
2416 Some("Textract.DetectDocumentText")
2417 );
2418 let authorization = request
2419 .headers
2420 .get("authorization")
2421 .expect("aws authorization");
2422 assert!(authorization.starts_with("AWS4-HMAC-SHA256 "));
2423
2424 let body: serde_json::Value =
2425 serde_json::from_slice(&request.body).expect("aws request JSON");
2426 let encoded = body["Document"]["Bytes"]
2427 .as_str()
2428 .expect("aws document bytes");
2429 let png = base64::engine::general_purpose::STANDARD
2430 .decode(encoded)
2431 .expect("decode aws image");
2432 assert!(png.starts_with(&[0x89, b'P', b'N', b'G']));
2433
2434 MockResponse::json(
2435 200,
2436 r#"{
2437 "Blocks": [
2438 {
2439 "BlockType": "LINE",
2440 "Text": "Hello Textract",
2441 "Confidence": 97.5,
2442 "Geometry": {
2443 "BoundingBox": {
2444 "Left": 0.1,
2445 "Top": 0.2,
2446 "Width": 0.5,
2447 "Height": 0.1
2448 }
2449 }
2450 }
2451 ]
2452 }"#,
2453 )
2454 });
2455
2456 let backend =
2457 AwsTextractBackend::with_endpoint("eu-west-1", "access", "secret", &server.url("/"));
2458 let (pixels, width, height) = test_text_image("TEST");
2459 let result = backend.recognize(&pixels, width, height).expect("AWS OCR");
2460
2461 assert_eq!(result.text, "Hello Textract");
2462 assert_eq!(result.words.len(), 1);
2463 assert_eq!(result.words[0].text, "Hello Textract");
2464 assert!((result.words[0].bbox[0] - (width as f32 * 0.1)).abs() < 0.001);
2465 assert!((result.words[0].confidence - 0.975).abs() < 0.0001);
2466
2467 server.finish();
2468 }
2469
2470 #[cfg(feature = "ocr-azure")]
2471 #[test]
2472 fn ocr_azure_name_is_stable() {
2473 let backend =
2474 AzureDocIntelBackend::new("https://example.cognitiveservices.azure.com", "key");
2475 assert_eq!(backend.name(), "azure-doc-intel");
2476 }
2477
2478 #[cfg(feature = "ocr-azure")]
2479 #[test]
2480 fn ocr_azure_from_env_reads_endpoint_and_key() {
2481 let _env = env_lock().lock().expect("env lock");
2482 let _scoped = ScopedEnv::set(&[
2483 (
2484 "AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT",
2485 Some("https://example.cognitiveservices.azure.com"),
2486 ),
2487 ("AZURE_DOCUMENT_INTELLIGENCE_KEY", Some("test-key")),
2488 ]);
2489
2490 let backend = AzureDocIntelBackend::from_env().expect("Azure backend from env");
2491 assert_eq!(backend.name(), "azure-doc-intel");
2492 }
2493
2494 #[cfg(feature = "ocr-azure")]
2495 #[test]
2496 fn ocr_azure_mock_request_polls_operation_and_parses_words() {
2497 let server = MockServer::start(2, |request, index, base_url| match index {
2498 0 => {
2499 assert_eq!(request.method, "POST");
2500 assert!(request
2501 .path
2502 .starts_with("/documentintelligence/documentModels/prebuilt-read:analyze?"));
2503 assert_eq!(
2504 request
2505 .headers
2506 .get("ocp-apim-subscription-key")
2507 .map(String::as_str),
2508 Some("test-key")
2509 );
2510 assert_eq!(
2511 request.headers.get("content-type").map(String::as_str),
2512 Some("application/octet-stream")
2513 );
2514 assert!(request.body.starts_with(&[0x89, b'P', b'N', b'G']));
2515
2516 MockResponse::empty(
2517 202,
2518 &[("Operation-Location", &format!("{base_url}/operations/123"))],
2519 )
2520 }
2521 1 => {
2522 assert_eq!(request.method, "GET");
2523 assert_eq!(request.path, "/operations/123");
2524
2525 MockResponse::json(
2526 200,
2527 r#"{
2528 "status": "succeeded",
2529 "analyzeResult": {
2530 "content": "Hello Azure",
2531 "pages": [
2532 {
2533 "words": [
2534 {
2535 "content": "Hello",
2536 "confidence": 0.98,
2537 "polygon": [1,2, 31,2, 31,12, 1,12]
2538 },
2539 {
2540 "content": "Azure",
2541 "confidence": 0.96,
2542 "polygon": [40,2, 78,2, 78,12, 40,12]
2543 }
2544 ]
2545 }
2546 ]
2547 }
2548 }"#,
2549 )
2550 }
2551 _ => unreachable!(),
2552 });
2553
2554 let backend = AzureDocIntelBackend::new(&server.base_url, "test-key");
2555 let (pixels, width, height) = test_text_image("TEST");
2556 let result = backend
2557 .recognize(&pixels, width, height)
2558 .expect("Azure OCR");
2559
2560 assert_eq!(result.text, "Hello Azure");
2561 assert_eq!(result.words.len(), 2);
2562 assert_eq!(result.words[0].bbox, [1.0, 2.0, 30.0, 10.0]);
2563 assert!((result.confidence - 0.97).abs() < 0.001);
2564
2565 server.finish();
2566 }
2567
2568 #[cfg(any(
2569 feature = "ocr-mistral",
2570 feature = "ocr-google",
2571 feature = "ocr-azure",
2572 feature = "ocr-aws"
2573 ))]
2574 #[test]
2575 fn ocr_best_available_respects_cloud_priority() {
2576 let _env = env_lock().lock().expect("env lock");
2577
2578 #[cfg(feature = "ocr-mistral")]
2579 {
2580 let _scoped = ScopedEnv::set(&[
2581 ("MISTRAL_API_KEY", Some("mistral-key")),
2582 ("GOOGLE_VISION_API_KEY", Some("google-key")),
2583 (
2584 "AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT",
2585 Some("https://example.azure.com"),
2586 ),
2587 ("AZURE_DOCUMENT_INTELLIGENCE_KEY", Some("azure-key")),
2588 ("AWS_REGION", Some("eu-west-1")),
2589 ("AWS_ACCESS_KEY_ID", Some("aws-access")),
2590 ("AWS_SECRET_ACCESS_KEY", Some("aws-secret")),
2591 ]);
2592
2593 let backend = best_available_backend().expect("best OCR backend");
2594 assert_eq!(backend.name(), "mistral");
2595 }
2596
2597 #[cfg(all(not(feature = "ocr-mistral"), feature = "ocr-google"))]
2598 {
2599 let _scoped = ScopedEnv::set(&[
2600 ("GOOGLE_VISION_API_KEY", Some("google-key")),
2601 (
2602 "AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT",
2603 Some("https://example.azure.com"),
2604 ),
2605 ("AZURE_DOCUMENT_INTELLIGENCE_KEY", Some("azure-key")),
2606 ("AWS_REGION", Some("eu-west-1")),
2607 ("AWS_ACCESS_KEY_ID", Some("aws-access")),
2608 ("AWS_SECRET_ACCESS_KEY", Some("aws-secret")),
2609 ]);
2610
2611 let backend = best_available_backend().expect("best OCR backend");
2612 assert_eq!(backend.name(), "google-vision");
2613 return;
2614 }
2615
2616 #[cfg(all(
2617 not(feature = "ocr-mistral"),
2618 not(feature = "ocr-google"),
2619 feature = "ocr-azure"
2620 ))]
2621 {
2622 let _scoped = ScopedEnv::set(&[
2623 (
2624 "AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT",
2625 Some("https://example.azure.com"),
2626 ),
2627 ("AZURE_DOCUMENT_INTELLIGENCE_KEY", Some("azure-key")),
2628 ("AWS_REGION", Some("eu-west-1")),
2629 ("AWS_ACCESS_KEY_ID", Some("aws-access")),
2630 ("AWS_SECRET_ACCESS_KEY", Some("aws-secret")),
2631 ]);
2632
2633 let backend = best_available_backend().expect("best OCR backend");
2634 assert_eq!(backend.name(), "azure-doc-intel");
2635 return;
2636 }
2637
2638 #[cfg(all(
2639 not(feature = "ocr-mistral"),
2640 not(feature = "ocr-google"),
2641 not(feature = "ocr-azure"),
2642 feature = "ocr-aws"
2643 ))]
2644 {
2645 let _scoped = ScopedEnv::set(&[
2646 ("AWS_REGION", Some("eu-west-1")),
2647 ("AWS_ACCESS_KEY_ID", Some("aws-access")),
2648 ("AWS_SECRET_ACCESS_KEY", Some("aws-secret")),
2649 ]);
2650
2651 let backend = best_available_backend().expect("best OCR backend");
2652 assert_eq!(backend.name(), "aws-textract");
2653 }
2654 }
2655
2656 fn test_text_image(text: &str) -> (Vec<u8>, u32, u32) {
2657 let scale = 8usize;
2658 let glyph_width = 5usize;
2659 let glyph_height = 7usize;
2660 let spacing = 2usize;
2661 let margin = 12usize;
2662 let width = margin * 2
2663 + text
2664 .chars()
2665 .map(|ch| match ch {
2666 ' ' => scale * 3,
2667 _ => glyph_width * scale + spacing * scale,
2668 })
2669 .sum::<usize>();
2670 let height = margin * 2 + glyph_height * scale;
2671 let mut pixels = vec![255u8; width * height * 3];
2672 let mut cursor_x = margin;
2673
2674 for ch in text.chars() {
2675 if ch == ' ' {
2676 cursor_x += scale * 3;
2677 continue;
2678 }
2679
2680 draw_glyph(
2681 &mut pixels,
2682 width,
2683 cursor_x,
2684 margin,
2685 scale,
2686 glyph_pattern(ch),
2687 );
2688 cursor_x += glyph_width * scale + spacing * scale;
2689 }
2690
2691 (pixels, width as u32, height as u32)
2692 }
2693
2694 fn draw_glyph(
2695 pixels: &mut [u8],
2696 image_width: usize,
2697 offset_x: usize,
2698 offset_y: usize,
2699 scale: usize,
2700 glyph: [&str; 7],
2701 ) {
2702 for (row, pattern) in glyph.into_iter().enumerate() {
2703 for (col, bit) in pattern.bytes().enumerate() {
2704 if bit != b'#' {
2705 continue;
2706 }
2707
2708 for dy in 0..scale {
2709 for dx in 0..scale {
2710 let x = offset_x + col * scale + dx;
2711 let y = offset_y + row * scale + dy;
2712 let idx = (y * image_width + x) * 3;
2713 pixels[idx] = 0;
2714 pixels[idx + 1] = 0;
2715 pixels[idx + 2] = 0;
2716 }
2717 }
2718 }
2719 }
2720 }
2721
2722 fn glyph_pattern(ch: char) -> [&'static str; 7] {
2723 match ch {
2724 '1' => [
2725 "..#..", ".##..", "..#..", "..#..", "..#..", "..#..", ".###.",
2726 ],
2727 '2' => [
2728 ".###.", "#...#", "....#", "...#.", "..#..", ".#...", "#####",
2729 ],
2730 '3' => [
2731 ".###.", "#...#", "....#", "..##.", "....#", "#...#", ".###.",
2732 ],
2733 'E' => [
2734 "#####", "#....", "#....", "####.", "#....", "#....", "#####",
2735 ],
2736 'S' => [
2737 ".####", "#....", "#....", ".###.", "....#", "....#", "####.",
2738 ],
2739 'T' => [
2740 "#####", "..#..", "..#..", "..#..", "..#..", "..#..", "..#..",
2741 ],
2742 _ => panic!("unsupported glyph: {ch}"),
2743 }
2744 }
2745}