Skip to main content

spdf_ocr/
http.rs

1//! HTTP OCR client. See `liteparse/OCR_API_SPEC.md`.
2//!
3//! Uses a shared tokio runtime so the rest of the spdf pipeline can stay
4//! synchronous. Creating a full runtime per request is wasteful, so we keep a
5//! lazily-initialised `current_thread` runtime internally.
6
7use std::sync::OnceLock;
8use std::time::Duration;
9
10use serde::Deserialize;
11use spdf_types::{SpdfError, SpdfResult};
12use tokio::runtime::Runtime;
13
14use crate::engine::{OcrEngine, OcrOptions, OcrResult};
15
16fn rt() -> SpdfResult<&'static Runtime> {
17    static RT: OnceLock<Runtime> = OnceLock::new();
18    if let Some(r) = RT.get() {
19        return Ok(r);
20    }
21    let r = tokio::runtime::Builder::new_current_thread()
22        .enable_all()
23        .build()
24        .map_err(|e| SpdfError::Ocr(format!("tokio runtime: {e}")))?;
25    // Racing threads may both try to init; OnceLock handles it.
26    Ok(RT.get_or_init(|| r))
27}
28
29/// HTTP OCR client for servers implementing the LiteParse OCR API.
30#[derive(Debug, Clone)]
31pub struct HttpOcrEngine {
32    url: String,
33    client: reqwest::Client,
34}
35
36impl HttpOcrEngine {
37    pub fn new(url: impl Into<String>) -> Self {
38        let client = reqwest::Client::builder()
39            .timeout(Duration::from_secs(120))
40            .build()
41            .expect("reqwest client builds");
42        Self {
43            url: url.into(),
44            client,
45        }
46    }
47}
48
49#[derive(Debug, Deserialize)]
50struct Response {
51    results: Vec<OcrResult>,
52}
53
54impl OcrEngine for HttpOcrEngine {
55    fn name(&self) -> &'static str {
56        "http"
57    }
58
59    fn recognize(&self, image: &[u8], options: &OcrOptions) -> SpdfResult<Vec<OcrResult>> {
60        let url = self.url.clone();
61        let client = self.client.clone();
62        let language = options
63            .languages
64            .first()
65            .cloned()
66            .unwrap_or_else(|| "en".into());
67        let image = image.to_vec();
68
69        rt()?.block_on(async move {
70            let form = reqwest::multipart::Form::new()
71                .part(
72                    "file",
73                    reqwest::multipart::Part::bytes(image)
74                        .file_name("page.png")
75                        .mime_str("image/png")
76                        .map_err(|e| SpdfError::Ocr(format!("mime: {e}")))?,
77                )
78                .text("language", language);
79
80            let resp = client
81                .post(&url)
82                .multipart(form)
83                .send()
84                .await
85                .map_err(|e| SpdfError::Ocr(format!("http send: {e}")))?;
86
87            if !resp.status().is_success() {
88                return Err(SpdfError::Ocr(format!(
89                    "OCR server returned HTTP {}",
90                    resp.status()
91                )));
92            }
93            let body: Response = resp
94                .json()
95                .await
96                .map_err(|e| SpdfError::Ocr(format!("http decode: {e}")))?;
97            Ok(body.results)
98        })
99    }
100}