Files
papercrate/backend/src/workers/ocr.rs
T
2025-11-20 16:54:12 +01:00

425 lines
12 KiB
Rust

use std::{
fmt, fs,
io::{ErrorKind, Write},
process::Command,
sync::Arc,
time::Duration,
};
use async_trait::async_trait;
use chrono::Utc;
use diesel::{pg::upsert::excluded, prelude::*};
use pdfium_render::prelude::*;
use serde_json::json;
use tempfile::NamedTempFile;
use tokio::task;
use tracing::{info, warn};
use uuid::Uuid;
use crate::{
documents::asset::delete_asset,
error::AppResult,
models::{Document, DocumentAsset, DocumentVersion, NewDocumentAsset},
schema::document_assets,
state::AppState,
utils::storage_paths::document_asset_key,
};
use super::taskflow::{
document::DocumentVersionTaskContext, Task, TaskContext, TaskError, TaskResult,
};
pub const OCR_TEXT_ASSET_TYPE: &str = "ocr-text";
const MIN_TEXT_LENGTH: usize = 50;
pub struct GenerateOcrTask {
force: bool,
state: Arc<AppState>,
}
impl GenerateOcrTask {
pub fn new(force: bool, state: Arc<AppState>) -> Self {
Self { force, state }
}
}
#[async_trait]
impl Task<DocumentVersionTaskContext> for GenerateOcrTask {
fn name(&self) -> &'static str {
"generate-ocr-text"
}
async fn execute(&self, ctx: &mut DocumentVersionTaskContext) -> TaskResult<()> {
let context = build_ocr_context(ctx, self.force).await?;
if context.skip {
info!(job_id = %ctx.job_id(), "ocr already present; skipping");
return Ok(());
}
let bytes = ctx.buffered_object().await?.to_vec();
let meta = PdfDocumentMeta {
content_type: context.document.content_type.clone(),
original_name: context.document.original_name.clone(),
};
let generation = task::spawn_blocking(move || generate_ocr_text(&meta, &bytes))
.await
.map_err(|err| {
TaskError::retry(
Duration::from_secs(60),
format!("ocr text task panicked: {err}"),
)
})?;
let Some(generation) = generation else {
warn!(job_id = %ctx.job_id(), "no text extracted from document; failing job");
return Err(TaskError::fail("no text extracted and OCR unavailable"));
};
remove_existing_ocr_asset(ctx, &context).await;
let asset_id = Uuid::new_v4();
let s3_key = document_asset_key(
context.document.id,
context.version.version_number,
OCR_TEXT_ASSET_TYPE,
asset_id,
);
ctx.storage()
.put_object(
&s3_key,
generation.text.into_bytes(),
Some("text/plain; charset=utf-8".into()),
None,
)
.await
.map_err(|err| TaskError::retry(Duration::from_secs(30), err.to_string()))?;
let state = self.state.clone();
task::spawn_blocking(move || {
persist_ocr_metadata(state, &context, asset_id, &s3_key, generation.source)
})
.await
.map_err(|err| {
TaskError::retry(
Duration::from_secs(60),
format!("ocr metadata task panicked: {err}"),
)
})?
.map_err(|err| TaskError::retry(Duration::from_secs(30), err))?;
ctx.invalidate_asset_cache();
Ok(())
}
}
struct OcrContext {
document: Document,
version: DocumentVersion,
existing_asset: Option<DocumentAsset>,
skip: bool,
}
async fn build_ocr_context(
ctx: &mut DocumentVersionTaskContext,
force: bool,
) -> TaskResult<OcrContext> {
let document = ctx.document().await?.clone();
let version = ctx.version().await?.clone();
let asset = ctx.asset(OCR_TEXT_ASSET_TYPE).await?;
let existing_asset = asset.map(|asset| asset.asset.clone());
if !document_is_pdf(&document) {
return Ok(OcrContext {
document,
version,
existing_asset,
skip: true,
});
}
let skip = existing_asset.is_some() && !force;
Ok(OcrContext {
document,
version,
existing_asset,
skip,
})
}
async fn remove_existing_ocr_asset(ctx: &DocumentVersionTaskContext, context: &OcrContext) {
if let Some(existing_asset) = &context.existing_asset {
if let Err(err) = ctx.storage().delete_object(&existing_asset.s3_key).await {
warn!(
job_id = %ctx.job_id(),
error = %err,
s3_key = %existing_asset.s3_key,
"failed to delete existing ocr asset object"
);
}
let tenant_id = context.document.tenant_id;
let asset_id = existing_asset.id;
let state = ctx.state().clone();
match task::spawn_blocking(move || -> AppResult<()> {
let mut conn = state.db_for_tenant(tenant_id)?;
delete_asset(&mut conn, tenant_id, asset_id)
})
.await
{
Ok(Ok(())) => {}
Ok(Err(err)) => {
warn!(
job_id = %ctx.job_id(),
error = ?err,
asset_id = %asset_id,
"failed to remove ocr asset metadata after deletion"
);
}
Err(join_err) => {
warn!(
job_id = %ctx.job_id(),
error = %join_err,
asset_id = %asset_id,
"failed to remove ocr asset metadata: task panicked"
);
}
}
}
}
fn persist_ocr_metadata(
state: Arc<AppState>,
context: &OcrContext,
asset_id: Uuid,
s3_key: &str,
source: OcrSource,
) -> Result<(), String> {
let tenant_id = context.document.tenant_id;
let mut conn = state
.db_for_tenant(tenant_id)
.map_err(|err| format!("{err:?}"))?;
let document_version_id = context.version.id;
let existing_asset = context.existing_asset.as_ref().map(|asset| asset.id);
if let Some(existing_asset) = existing_asset {
diesel::delete(
document_assets::table
.filter(document_assets::id.eq(existing_asset))
.filter(document_assets::tenant_id.eq(tenant_id)),
)
.execute(&mut conn)
.map_err(|err| format!("{err:?}"))?;
}
let metadata = json!({
"source": source.to_string(),
"generated_at": Utc::now().to_rfc3339(),
});
let new_asset = NewDocumentAsset {
id: asset_id,
document_version_id,
asset_type: OCR_TEXT_ASSET_TYPE.to_string(),
mime_type: "text/plain".to_string(),
metadata,
s3_key: s3_key.to_string(),
tenant_id,
};
diesel::insert_into(document_assets::table)
.values(&new_asset)
.on_conflict((
document_assets::document_version_id,
document_assets::asset_type,
))
.do_update()
.set((
document_assets::mime_type.eq(excluded(document_assets::mime_type)),
document_assets::metadata.eq(excluded(document_assets::metadata)),
document_assets::s3_key.eq(excluded(document_assets::s3_key)),
document_assets::id.eq(excluded(document_assets::id)),
))
.execute(&mut conn)
.map_err(|err| format!("{err:?}"))?;
Ok(())
}
fn generate_ocr_text(meta: &PdfDocumentMeta, bytes: &[u8]) -> Option<OcrGeneration> {
if !document_meta_is_pdf(meta) {
return None;
}
if let Ok(text) = extract_pdf_text(bytes) {
if text.trim().chars().count() >= MIN_TEXT_LENGTH {
return Some(OcrGeneration {
text,
source: OcrSource::PdfText,
});
}
}
match run_ocr(bytes) {
Ok(Some(text)) => Some(OcrGeneration {
text,
source: OcrSource::Ocr,
}),
Ok(None) => None,
Err(OcrError::BinaryMissing) => {
warn!("ocrmypdf binary not found; OCR unavailable");
None
}
Err(err) => {
warn!(error = %err, "ocr command failed");
None
}
}
}
struct PdfDocumentMeta {
content_type: Option<String>,
original_name: String,
}
struct OcrGeneration {
text: String,
source: OcrSource,
}
#[derive(Clone, Copy)]
enum OcrSource {
PdfText,
Ocr,
}
impl fmt::Display for OcrSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
OcrSource::PdfText => write!(f, "pdf-text"),
OcrSource::Ocr => write!(f, "ocr"),
}
}
}
#[derive(Debug)]
enum OcrError {
BinaryMissing,
Failed(String),
}
impl fmt::Display for OcrError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
OcrError::BinaryMissing => write!(f, "ocrmypdf binary not found"),
OcrError::Failed(msg) => write!(f, "ocr failed: {msg}"),
}
}
}
fn extract_pdf_text(bytes: &[u8]) -> Result<String, String> {
let pdfium = Pdfium::default();
let document = pdfium
.load_pdf_from_byte_slice(bytes, None)
.map_err(|err| format!("load pdf: {err}"))?;
let mut combined = String::new();
let pages = document.pages();
for page_index in 0..pages.len() {
let page = pages
.get(page_index)
.map_err(|err| format!("load page {page_index}: {err}"))?;
if let Ok(page_text) = page.text() {
for segment in page_text.segments().iter() {
combined.push_str(&segment.text());
combined.push('\n');
}
};
}
Ok(combined)
}
fn run_ocr(bytes: &[u8]) -> Result<Option<String>, OcrError> {
let mut input = NamedTempFile::new().map_err(|err| OcrError::Failed(err.to_string()))?;
input
.write_all(bytes)
.map_err(|err| OcrError::Failed(err.to_string()))?;
input
.flush()
.map_err(|err| OcrError::Failed(err.to_string()))?;
let output_pdf = NamedTempFile::new().map_err(|err| OcrError::Failed(err.to_string()))?;
let sidecar = NamedTempFile::new().map_err(|err| OcrError::Failed(err.to_string()))?;
let status = Command::new("ocrmypdf")
.arg("--sidecar")
.arg(sidecar.path())
.arg("--skip-text")
.arg(input.path())
.arg(output_pdf.path())
.output();
match status {
Ok(output) => {
if !output.status.success() {
return Err(OcrError::Failed(format!(
"ocrmypdf failed: exit={} stderr={}",
output.status,
String::from_utf8_lossy(&output.stderr)
)));
}
let text = fs::read_to_string(sidecar.path())
.map_err(|err| OcrError::Failed(err.to_string()))?;
if text.trim().chars().count() >= MIN_TEXT_LENGTH {
Ok(Some(text))
} else {
Ok(None)
}
}
Err(err) => {
if err.kind() == ErrorKind::NotFound {
Err(OcrError::BinaryMissing)
} else {
Err(OcrError::Failed(err.to_string()))
}
}
}
}
fn document_meta_is_pdf(meta: &PdfDocumentMeta) -> bool {
if let Some(content_type) = &meta.content_type {
if content_type.eq_ignore_ascii_case("application/pdf") {
return true;
}
}
meta.original_name
.rsplit('.')
.next()
.map(|ext| ext.eq_ignore_ascii_case("pdf"))
.unwrap_or(false)
}
fn document_is_pdf(document: &Document) -> bool {
document
.content_type
.as_deref()
.map(|mime| mime.eq_ignore_ascii_case("application/pdf"))
.unwrap_or_else(|| {
document
.original_name
.rsplit('.')
.next()
.map(|ext| ext.eq_ignore_ascii_case("pdf"))
.unwrap_or(false)
})
}