Skip to main content

pdf_engine/
ocr.rs

1//! OCR backend trait and implementations.
2//!
3//! ## OCR Backend Extension
4//!
5//! The [`OcrBackend`] trait is public. Callers can implement their own backends
6//! for specific use cases:
7//!
8//! - **ocrs** (this crate, `ocr` feature): pure-Rust Tesseract-style engine.
9//!   WASM-compatible. Requires external model files (`.rten` format).
10//!
11//! - **Mistral OCR** (`ocr-mistral` feature): blocking HTTP adapter to
12//!   Mistral's cloud OCR API.
13//!
14//! - **Google Vision** (`ocr-google` feature): blocking HTTP adapter to
15//!   Google Cloud Vision's `images:annotate` OCR endpoint.
16//!
17//! - **AWS Textract** (`ocr-aws` feature): blocking HTTP adapter to
18//!   Amazon Textract's `DetectDocumentText` endpoint.
19//!
20//! - **Azure Document Intelligence** (`ocr-azure` feature): blocking HTTP
21//!   adapter to the `prebuilt-read` model.
22//!
23//! - **Paddle ONNX** (`ocr-onnx` feature): adapter around `pdf-ocr`'s
24//!   PaddleOCR engine.
25//!
26//! - **Custom**: implement [`OcrBackend`] directly for proprietary or
27//!   cloud-based OCR flows.
28
29// Copyright (c) 2026 Innovation Trigger B.V.
30//
31// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
32// the PDFluent Commercial Licence. See the LICENSE file in this repository --
33// that file travels with the copy you received, which a URL does not.
34
35use 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// ── Public types ─────────────────────────────────────────────────────────────
84
85/// Result of recognizing text in a single image.
86#[derive(Debug, Clone)]
87pub struct OcrResult {
88    /// Full recognized text, words joined with spaces.
89    pub text: String,
90    /// Individual recognized words with positions and confidence.
91    pub words: Vec<OcrWord>,
92    /// Overall recognition confidence (0.0 – 1.0).
93    pub confidence: f32,
94}
95
96/// A single recognized word.
97#[derive(Debug, Clone)]
98pub struct OcrWord {
99    /// The recognized text of the word.
100    pub text: String,
101    /// Bounding box in image coordinates: `[x, y, width, height]` in pixels.
102    pub bbox: [f32; 4],
103    /// Word-level confidence (0.0 – 1.0).
104    pub confidence: f32,
105}
106
107/// Errors returned by an [`OcrBackend`].
108#[derive(Debug)]
109pub enum OcrError {
110    /// No OCR engine is available (models missing, feature not compiled, etc.).
111    NoEngine,
112    /// The input image data is invalid or cannot be decoded.
113    ImageError(String),
114    /// Recognition ran but produced an error.
115    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
130/// Pluggable OCR backend.
131///
132/// Implementations receive raw RGB pixel data and return recognized text.
133/// Implementations must be `Send + Sync` so they can be held in a `static`
134/// or shared across threads.
135pub trait OcrBackend: Send + Sync {
136    /// Recognize text in a rasterized page image.
137    ///
138    /// # Arguments
139    /// * `image_data` — raw RGB pixels, row-major, 3 bytes per pixel.
140    /// * `width`  — image width in pixels.
141    /// * `height` — image height in pixels.
142    fn recognize(&self, image_data: &[u8], width: u32, height: u32) -> Result<OcrResult, OcrError>;
143
144    /// Short identifier shown in test metadata (e.g. `"ocrs"`, `"mistral"`).
145    fn name(&self) -> &str;
146}
147
148// ── Shared helpers ───────────────────────────────────────────────────────────
149
150#[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// ── Mistral OCR backend ──────────────────────────────────────────────────────
253
254#[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/// Blocking HTTP OCR backend for the Mistral OCR API.
260#[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    /// Create a backend from an API key.
269    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    /// Create a backend from the `MISTRAL_API_KEY` environment variable.
277    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// ── Google Cloud Vision backend ──────────────────────────────────────────────
380
381#[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/// Blocking HTTP OCR backend for Google Cloud Vision.
389///
390/// Authentication supports:
391/// - API key via [`GoogleVisionBackend::from_api_key`]
392/// - Service-account JSON via [`GoogleVisionBackend::from_service_account`]
393/// - Auto-detection via [`GoogleVisionBackend::from_env`]
394#[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    /// Create a backend from an API key.
405    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    /// Create a backend from a service-account JSON file.
414    ///
415    /// The credentials are loaded eagerly and an OAuth access token is minted
416    /// on demand using the service-account private key.
417    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    /// Create a backend from Google Cloud environment variables.
440    ///
441    /// Resolution order:
442    /// 1. `GOOGLE_VISION_API_KEY`
443    /// 2. `GOOGLE_APPLICATION_CREDENTIALS`
444    /// 3. `GOOGLE_CLOUD_PROJECT` plus the standard gcloud application-default
445    ///    credentials file
446    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// ── AWS Textract backend ─────────────────────────────────────────────────────
955
956#[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/// Blocking HTTP OCR backend for AWS Textract.
964#[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    /// Create a backend from explicit IAM credentials.
977    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    /// Create a backend from the standard AWS environment/profile chain.
988    ///
989    /// Resolution order:
990    /// 1. `AWS_REGION` or `AWS_DEFAULT_REGION`
991    /// 2. `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, optional
992    ///    `AWS_SESSION_TOKEN`
993    /// 3. `AWS_PROFILE` (or `default`) in the shared credentials/config files
994    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                &region,
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// ── Azure Document Intelligence backend ─────────────────────────────────────
1407
1408#[cfg(feature = "ocr-azure")]
1409const AZURE_DOC_INTELLIGENCE_API_VERSION: &str = "2024-11-30";
1410
1411/// Blocking HTTP OCR backend for Azure AI Document Intelligence.
1412#[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    /// Create a backend from an explicit endpoint and API key.
1422    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    /// Create a backend from Azure environment variables.
1431    ///
1432    /// Required variables:
1433    /// - `AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT`
1434    /// - `AZURE_DOCUMENT_INTELLIGENCE_KEY`
1435    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// ── Paddle ONNX backend ──────────────────────────────────────────────────────
1664
1665/// OCR backend backed by `pdf-ocr`'s PaddleOCR ONNX pipeline.
1666#[cfg(feature = "ocr-onnx")]
1667pub struct PaddleOnnxBackend {
1668    engine: pdf_ocr::PaddleOcrEngine,
1669}
1670
1671#[cfg(feature = "ocr-onnx")]
1672impl PaddleOnnxBackend {
1673    /// Create the backend using the default PaddleOCR model cache/download flow.
1674    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    /// Create the backend from the environment.
1681    ///
1682    /// The underlying PaddleOCR engine resolves its own model cache and
1683    /// download locations, so this is currently equivalent to [`Self::new`].
1684    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// ── ocrs backend ─────────────────────────────────────────────────────────────
1727
1728/// Pure-Rust OCR backend backed by the [`ocrs`](https://crates.io/crates/ocrs)
1729/// engine (which uses ONNX models via `rten`).
1730///
1731/// # Model files
1732///
1733/// `ocrs` requires two pre-trained model files in `.rten` format:
1734///
1735/// | Model | Default env var | Default path |
1736/// |---|---|---|
1737/// | Text detection | `OCRS_DETECTION_MODEL` | `~/.cache/ocrs/text-detection.rten` |
1738/// | Text recognition | `OCRS_RECOGNITION_MODEL` | `~/.cache/ocrs/text-recognition.rten` |
1739///
1740/// Download links:
1741/// - <https://ocrs-models.s3-accelerate.amazonaws.com/text-detection.rten>
1742/// - <https://ocrs-models.s3-accelerate.amazonaws.com/text-recognition.rten>
1743///
1744/// # WASM compatibility
1745///
1746/// This backend is WASM-compatible. On WASM use [`OcrsBackend::from_bytes`]
1747/// since filesystem access is unavailable.
1748#[cfg(feature = "ocr")]
1749pub struct OcrsBackend {
1750    engine: ocrs::OcrEngine,
1751}
1752
1753#[cfg(feature = "ocr")]
1754impl OcrsBackend {
1755    /// Create from pre-loaded model bytes.
1756    ///
1757    /// Suitable for WASM where filesystem access is unavailable.
1758    /// Pass the raw `.rten` file bytes for each model.
1759    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    /// Create from filesystem paths to the `.rten` model files.
1766    ///
1767    /// Not available on WASM (use [`OcrsBackend::from_bytes`] instead).
1768    #[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    /// Try to initialize from standard locations.
1779    ///
1780    /// Checks `OCRS_DETECTION_MODEL` / `OCRS_RECOGNITION_MODEL` env vars first,
1781    /// then falls back to `~/.cache/ocrs/{text-detection,text-recognition}.rten`.
1782    ///
1783    /// Returns `Err(OcrError::NoEngine)` when no models are found.
1784    /// Not available on WASM (use [`OcrsBackend::from_bytes`] instead).
1785    #[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/// Returns `~/.cache/ocrs/<filename>` as a `Some(PathBuf)` if the home dir is
1815/// resolvable, `None` otherwise.
1816#[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// ── Convenience functions ────────────────────────────────────────────────────
1861
1862/// Return the first available OCR backend in priority order.
1863///
1864/// The current order is:
1865/// 1. `ocr-mistral` with `MISTRAL_API_KEY`
1866/// 2. `ocr-google` with `GOOGLE_VISION_API_KEY` or Google credentials
1867/// 3. `ocr-azure` with `AZURE_DOCUMENT_INTELLIGENCE_*`
1868/// 4. `ocr-aws` with standard AWS credentials
1869/// 5. `ocr-onnx` via the Paddle ONNX adapter
1870/// 6. `ocr` via local `ocrs` model files
1871#[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/// Run OCR on a rendered page image using the default `ocrs` backend.
1907///
1908/// Initialises `OcrsBackend` from the standard model paths on every call.
1909/// For repeated use, create an `OcrsBackend` once and call
1910/// `ocr_page` with a reference to it.
1911#[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    // Tests use GOOGLE_APPLICATION_CREDENTIALS env var pointing to a real service account JSON.
1938    // Never hardcode private keys in source — set the env var before running ocr-google tests.
1939
1940    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, //
2159            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            // Assembled rather than written as one literal: a PEM header in
2350            // shipped source trips secret scanners (including our own
2351            // prepublish audit) even when the body is obviously fake.
2352            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}