use super::*;
const MAX_IMAGE_PIXELS: u64 = 500_000_000;
pub(super) const OCR_OFF: &str =
"images need OCR turned on on this server: [ocr] enabled = true or --ocr";
impl Scraper {
pub(super) async fn image_document(
&self,
bytes: Vec<u8>,
opts: &ScrapeOptions,
(meta, mut ext): (Metadata, PagesiftMeta),
mut p: Progress,
deadline: Option<Instant>,
) -> Result<Document, ApiError> {
let Some(ocr) = self.ocr.clone() else {
return Err(ApiError::unsupported(OCR_OFF));
};
let stage = self
.policy
.fetcher()
.acquire_stage(pagesift_net::NetworkStage::Parse)
.await;
let t = Instant::now();
let engine = ocr.clone();
let result = cpu_task(move || {
ocr.prepare(deadline)?;
let image = pagesift_pdf::decode_image_file(&bytes, MAX_IMAGE_PIXELS)?;
drop(bytes);
ocr.recognize(image.width, image.height, &image.pixels, deadline)
})
.await?;
let output = match result {
Ok(output) => {
stage.finish(pagesift_net::StageOutcome::Success);
output
}
Err(message) => {
stage.finish(pagesift_net::StageOutcome::DeterministicFailure);
return Err(ApiError::unsupported(format!(
"could not read image: {message}"
)));
}
};
p.timings.insert("ocr".into(), ms(t.elapsed()));
if let Some(reason) = &output.fallback {
p.warnings.push(format!("ocr_fallback: {reason}"));
}
if let Some(reason) = &output.partial_error {
p.warnings.push(format!("ocr_partial: {reason}"));
}
let markdown = lines_to_markdown(&output.lines);
if markdown.is_empty() {
p.warnings
.push("ocr_empty: no text was recognized in this image".into());
}
let info = engine.info();
ext.ocr = Some(pagesift_api::types::OcrMeta {
engine: info.engine,
model: info.model,
device: output.device.clone().unwrap_or(info.device),
pages: 1,
fallback: output.fallback.is_some(),
});
Ok(finish(
Document {
markdown: opts.wants("markdown").then_some(markdown),
..Default::default()
},
meta,
ext,
p.timings,
p.warnings,
None,
false,
p.started,
))
}
}
fn lines_to_markdown(lines: &[pagesift_pdf::OcrLine]) -> String {
let mut sorted: Vec<&pagesift_pdf::OcrLine> =
lines.iter().filter(|l| !l.text.trim().is_empty()).collect();
sorted.sort_by(|a, b| a.y0.total_cmp(&b.y0));
let mut rows: Vec<Vec<&pagesift_pdf::OcrLine>> = Vec::new();
for line in sorted {
let h = (line.y1 - line.y0).max(1.0);
let same = rows.last().is_some_and(|r: &Vec<_>| {
let last = r[r.len() - 1];
!line.formula && !last.formula && (last.y1.min(line.y1) - line.y0) > h * 0.5
});
if same {
if let Some(r) = rows.last_mut() {
r.push(line);
}
} else {
rows.push(vec![line]);
}
}
let mut out = String::new();
let mut prev_bottom: Option<f32> = None;
let mut prev_formula = false;
for row in &mut rows {
row.sort_by(|a, b| a.x0.total_cmp(&b.x0));
let top = row.iter().map(|l| l.y0).fold(f32::MAX, f32::min);
let bottom = row.iter().map(|l| l.y1).fold(f32::MIN, f32::max);
let height = (bottom - top).max(1.0);
let formula = row[0].formula;
if let Some(prev) = prev_bottom {
let new_para = formula || prev_formula || top - prev > height * 0.5;
out.push_str(if new_para { "\n\n" } else { "\n" });
}
let text: Vec<&str> = row.iter().map(|l| l.text.trim()).collect();
out.push_str(&text.join(" "));
prev_bottom = Some(bottom);
prev_formula = formula;
}
if !out.is_empty() {
out.push('\n');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn line(x0: f32, y0: f32, y1: f32, text: &str, formula: bool) -> pagesift_pdf::OcrLine {
pagesift_pdf::OcrLine {
x0,
y0,
x1: x0 + 10.0,
y1,
text: text.into(),
score: 1.0,
formula,
}
}
#[test]
fn reading_order_rows_and_paragraphs() {
let lines = [
line(50.0, 0.0, 10.0, "right", false),
line(0.0, 1.0, 11.0, "left", false),
line(0.0, 12.0, 22.0, "next", false),
line(0.0, 60.0, 70.0, "para", false),
line(0.0, 72.0, 82.0, "$$x$$", true),
line(0.0, 84.0, 94.0, " ", false),
];
assert_eq!(
lines_to_markdown(&lines),
"left right\nnext\n\npara\n\n$$x$$\n"
);
assert_eq!(lines_to_markdown(&[]), "");
}
}