mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 04:22:20 +00:00
M10: read OCR jobs a page at a time; install a downloaded engine (ocr.ocr-web)
`OcrJob::steps` splits `run` where the engine reads: `OcrSteps::next_page` renders the page, crops it to the region and preprocesses it, an engine reads `OcrShot::image`, and `OcrShot::place` maps the words back through the preprocessing and the crop. `run` is the same loop with `OcrShot::read`, so desktop results don't change. With only one engine, every strategy is that engine's reading, which is what the web build's workers return. `FileJob` splits `recognize_file` the same way (open, read, finish), keeping the skip-files probe on opening. `ocr::install` puts a recogniser loaded from downloaded bytes in the engine cache, so `recognizers`, `available`, `engine_unavailable_reason` and `engines_status` see it.
This commit is contained in:
1 parent
9121f65564
commit
0bfb50a853
2 files changed
+409
-159
No files matched your search
+380
-159
@@ -15,7 +15,8 @@ use pdfcraft_render::text::TextFilter;
|
||||
use pdfcraft_render::{PageInfo, PageRenderer, RenderConfig, RenderRequest, RequestKind};
|
||||
|
||||
pub use pdfcraft_ocr::{
|
||||
ConfidenceBand, LANGUAGES, MergeStrategy, Models, Ocr, OcrError, PlacedWord, Recognizer, band_for, is_suspect, low_confidence,
|
||||
ConfidenceBand, DETECTION_MODEL, LANGUAGES, Line, MergeStrategy, Models, Ocr, OcrError, OcrImage, PlacedWord, RECOGNITION_MODEL, Recognizer,
|
||||
StrategyOutcome, Word, band_for, is_suspect, lines_from_json, low_confidence,
|
||||
};
|
||||
|
||||
use crate::{DocId, Edit, EditError, Session};
|
||||
@@ -257,6 +258,23 @@ struct EngineCache {
|
||||
ocrs: Option<Arc<dyn Recognizer>>,
|
||||
}
|
||||
|
||||
/// The engines built so far (see [`recognizers`] and [`install`]).
|
||||
static ENGINES: Mutex<Option<EngineCache>> = Mutex::new(None);
|
||||
|
||||
/// Share an ocrs recogniser loaded some other way than from the model files [`Models::find`]
|
||||
/// looks for: the web build downloads the models and loads them with [`Ocr::from_bytes`]. From
|
||||
/// then on [`recognizers`] hands it out and the engine counts as available.
|
||||
pub fn install(ocr: Ocr) -> Arc<dyn Recognizer> {
|
||||
let ocrs: Arc<dyn Recognizer> = Arc::new(pdfcraft_ocr::OcrsRecognizer::from_ocr(ocr));
|
||||
ENGINES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(|| EngineCache { ocrs: None }).ocrs = Some(ocrs.clone());
|
||||
ocrs
|
||||
}
|
||||
|
||||
/// Whether an ocrs recogniser has been loaded (from the model files, or by [`install`]).
|
||||
fn ocrs_loaded() -> bool {
|
||||
ENGINES.lock().unwrap_or_else(|e| e.into_inner()).as_ref().is_some_and(|c| c.ocrs.is_some())
|
||||
}
|
||||
|
||||
/// How long a negative engine probe (no program there) is remembered. A short TTL, not
|
||||
/// forever: a tesseract installed while the app runs is found by the next job, without a
|
||||
/// restart. An engine that was found is remembered for the session.
|
||||
@@ -408,10 +426,11 @@ fn models_installed_cached() -> bool {
|
||||
found
|
||||
}
|
||||
|
||||
/// The web build has no disk search worth caching.
|
||||
/// The web build has no model files: the models count as installed once [`install`] has
|
||||
/// loaded the ones it downloaded.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn models_installed_cached() -> bool {
|
||||
Models::find().is_some()
|
||||
ocrs_loaded()
|
||||
}
|
||||
|
||||
/// The program's `--version` line, remembered per program path for the session (`ocr_status`
|
||||
@@ -473,7 +492,7 @@ pub fn engines_status() -> Vec<EngineStatus> {
|
||||
/// takes a location, because an agent must not choose which program runs; the tools report the
|
||||
/// default settings.)
|
||||
pub fn engines_status_for(settings: &OcrSettings) -> Vec<EngineStatus> {
|
||||
let ocrs = Models::find().is_some();
|
||||
let ocrs = ocrs_loaded() || Models::find().is_some();
|
||||
let mut out = vec![EngineStatus { id: "ocrs", available: ocrs, why_not: (!ocrs).then(models_missing), program: None, version: None }];
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
@@ -524,7 +543,6 @@ pub fn recognizers(settings: &OcrSettings) -> Result<Recognizers, String> {
|
||||
let configured = configured_tesseract(settings);
|
||||
let tesseract =
|
||||
if matches!(settings.engine, EngineChoice::Tesseract | EngineChoice::Auto) { tesseract_engine(configured.as_deref()) } else { None };
|
||||
static ENGINES: Mutex<Option<EngineCache>> = Mutex::new(None);
|
||||
let mut slot = ENGINES.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let cache = slot.get_or_insert_with(|| EngineCache { ocrs: None });
|
||||
if cache.ocrs.is_none() {
|
||||
@@ -567,7 +585,7 @@ fn models_missing() -> String {
|
||||
/// never waits.
|
||||
pub fn available() -> bool {
|
||||
let configured = std::env::var_os("PDFCRAFT_TESSERACT").map(|v| v.to_string_lossy().into_owned());
|
||||
Models::find().is_some() || tesseract_engine(configured.as_deref()).is_some()
|
||||
ocrs_loaded() || Models::find().is_some() || tesseract_engine(configured.as_deref()).is_some()
|
||||
}
|
||||
|
||||
/// Why `engine` cannot run right now, named for the UI's tooltips (the Engine dropdown
|
||||
@@ -703,138 +721,226 @@ impl OcrJob {
|
||||
/// Read the pages. `progress(done, total)` is called before each page; returning `false`
|
||||
/// stops (the pages read so far are returned).
|
||||
pub fn run(self, recognizers: &Recognizers, mut progress: impl FnMut(usize, usize) -> bool) -> Vec<OcrPage> {
|
||||
let config =
|
||||
RenderConfig { password: self.password.as_deref().map(Arc::from), hide_comments: self.settings.native_text_only, ..Default::default() };
|
||||
let mut r = PageRenderer::new(self.bytes.clone(), config);
|
||||
let mut out = Vec::new();
|
||||
let total = self.pages.len();
|
||||
let options = pdfcraft_ocr::RecognizeOptions { language: self.settings.language.clone() };
|
||||
for (done, &page) in self.pages.iter().enumerate() {
|
||||
if !progress(done, total) {
|
||||
break;
|
||||
let mut steps = self.steps();
|
||||
let mut out = Vec::new();
|
||||
while progress(steps.done(), total) {
|
||||
match steps.next_page() {
|
||||
None => break,
|
||||
Some(Err(skipped)) => out.push(skipped),
|
||||
Some(Ok(shot)) => out.push(shot.read(recognizers)),
|
||||
}
|
||||
let Some(info) = self.infos.get(page) else { continue };
|
||||
let skip = |why: &str| OcrPage { page, words: Vec::new(), already_on_page: 0, skipped: Some(why.into()), notes: Vec::new() };
|
||||
// The boxes of all text on the page, in view space: an earlier OCR layer's, and words
|
||||
// only /ActualText names, so neither is read again.
|
||||
let existing: Vec<[f32; 4]> = r
|
||||
.render(RenderRequest { page, kind: RequestKind::Text, scale: 1.0, ..Default::default() })
|
||||
.text
|
||||
.map(|t| t.glyphs.iter().filter(|g| !g.text.trim().is_empty()).map(|g| g.rect).collect())
|
||||
.unwrap_or_default();
|
||||
if self.settings.skip_text_pages && !self.settings.force_ocr && !existing.is_empty() && has_own_text(&mut r, page) {
|
||||
out.push(skip("the page already contains text"));
|
||||
continue;
|
||||
}
|
||||
// A scan is read at its own resolution (enlarging it only blurs the letters), at
|
||||
// most the chosen one.
|
||||
let scale = self.render_scale(page);
|
||||
let shot = r.render(RenderRequest { page, scale, ..Default::default() });
|
||||
if let Some(e) = shot.error {
|
||||
out.push(skip(&e));
|
||||
continue;
|
||||
}
|
||||
let full = match pdfcraft_ocr::OcrImage::new(shot.width, shot.height, shot.rgba.to_vec()) {
|
||||
Ok(img) => img,
|
||||
Err(e) => {
|
||||
out.push(skip(&e.to_string()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// The optional region: crop the rendered page, clamped; an empty region skips the
|
||||
// page with a named reason (nothing was asked of it).
|
||||
let (image, offset) = match self.settings.region {
|
||||
None => (full, [0.0f32; 2]),
|
||||
Some(region) => match clamp_region(region, shot.width, shot.height) {
|
||||
Some([x0, y0, x1, y1]) => match crop(&full, x0, y0, x1, y1) {
|
||||
Some(c) => (c, [x0 as f32, y0 as f32]),
|
||||
None => {
|
||||
out.push(skip("the selected region is empty"));
|
||||
continue;
|
||||
}
|
||||
},
|
||||
None => {
|
||||
out.push(skip("the selected region is outside the page"));
|
||||
continue;
|
||||
}
|
||||
},
|
||||
};
|
||||
// Preprocessing sits between the page raster and recognition; its word boxes come
|
||||
// back in processed coordinates and are mapped to the ORIGINAL raster's before
|
||||
// placement. The render dpi is the real one (pixels per point × 72), never a
|
||||
// fixed value.
|
||||
let pre = pdfcraft_ocr::preprocess::preprocess(
|
||||
image,
|
||||
scale * 72.0,
|
||||
&pdfcraft_ocr::preprocess::PreprocessOptions {
|
||||
auto_rotate: self.settings.auto_rotate,
|
||||
deskew: self.settings.deskew,
|
||||
denoise: self.settings.denoise,
|
||||
binarize: self.settings.binarize,
|
||||
},
|
||||
);
|
||||
let (image, inverse) = match pre {
|
||||
Ok(p) => (p.image, p.inverse_transform),
|
||||
Err(e) => {
|
||||
out.push(skip(&e.to_string()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let outcome = match pdfcraft_ocr::recognize_with_strategy(
|
||||
recognizers.primary.as_ref(),
|
||||
recognizers.secondary.as_deref(),
|
||||
self.settings.strategy,
|
||||
&image,
|
||||
&options,
|
||||
) {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
out.push(skip(&e.to_string()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let scale = shot.width as f32 / info.width.max(1e-3);
|
||||
let to_user = |x: f32, y: f32| {
|
||||
// processed pixels → original raster pixels (the exact inverse) → past the
|
||||
// region crop → page view space.
|
||||
let (ox, oy) = inverse.apply(f64::from(x), f64::from(y));
|
||||
let [u, v] = info.view_to_user((ox as f32 + offset[0]) / scale, (oy as f32 + offset[1]) / scale);
|
||||
[u as f64, v as f64]
|
||||
};
|
||||
// Validate recognition geometry and compare against existing text in view space,
|
||||
// undoing preprocessing and region offsets before testing overlap.
|
||||
let (fresh, already): (Vec<_>, Vec<_>) = outcome.lines.iter().flat_map(|l| &l.words).filter(|w| box_writable(w)).partition(|w| {
|
||||
let [l, t, r, b] = w.rect;
|
||||
let bounds = [(l, t), (r, t), (r, b), (l, b)].into_iter().fold(
|
||||
[f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY],
|
||||
|mut bounds, (x, y)| {
|
||||
let (x, y) = inverse.apply(f64::from(x), f64::from(y));
|
||||
let x = (x as f32 + offset[0]) / scale;
|
||||
let y = (y as f32 + offset[1]) / scale;
|
||||
bounds[0] = bounds[0].min(x);
|
||||
bounds[1] = bounds[1].min(y);
|
||||
bounds[2] = bounds[2].max(x);
|
||||
bounds[3] = bounds[3].max(y);
|
||||
bounds
|
||||
},
|
||||
);
|
||||
covered(bounds, &existing) < ALREADY_ON_PAGE
|
||||
});
|
||||
let words = fresh.into_iter().map(|w| PlacedWord::place(w, to_user)).collect::<Vec<_>>();
|
||||
let mut notes = outcome.notes;
|
||||
// Only visible editable output needs display glyphs. Searchable output preserves
|
||||
// every Unicode character with empty Type3 programs and ToUnicode maps.
|
||||
if self.settings.output_mode == OutputMode::EditableText && !words.is_empty() {
|
||||
let missing = pdfcraft_fonts::TextPlan::for_words(words.iter().map(|w| w.text.as_str())).missing();
|
||||
if missing > 0 {
|
||||
notes.push(format!("{missing} characters had no font that could show them and appear as ? in the output"));
|
||||
}
|
||||
}
|
||||
out.push(OcrPage { page, words, already_on_page: already.len(), skipped: None, notes });
|
||||
}
|
||||
progress(total, total);
|
||||
out
|
||||
}
|
||||
|
||||
/// Read the pages one at a time, preparing each page's image and recognising it separately:
|
||||
/// the web build prepares pages on the page and recognises them in Web Workers.
|
||||
pub fn steps(self) -> OcrSteps {
|
||||
let config =
|
||||
RenderConfig { password: self.password.as_deref().map(Arc::from), hide_comments: self.settings.native_text_only, ..Default::default() };
|
||||
let renderer = PageRenderer::new(self.bytes.clone(), config);
|
||||
OcrSteps { job: self, renderer, next: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// [`OcrJob`] a page at a time: [`OcrSteps::next_page`] renders the next page, crops it to the
|
||||
/// region and preprocesses it; an engine reads [`OcrShot::image`], and [`OcrShot::place`] puts
|
||||
/// the words found on the page ([`OcrShot::read`] does both with the job's engines).
|
||||
pub struct OcrSteps {
|
||||
job: OcrJob,
|
||||
renderer: PageRenderer,
|
||||
next: usize,
|
||||
}
|
||||
|
||||
impl OcrSteps {
|
||||
/// The number of pages to read.
|
||||
pub fn total(&self) -> usize {
|
||||
self.job.pages.len()
|
||||
}
|
||||
|
||||
/// The number of pages prepared (or skipped) so far.
|
||||
pub fn done(&self) -> usize {
|
||||
self.next
|
||||
}
|
||||
|
||||
/// The settings the job reads with.
|
||||
pub fn settings(&self) -> &OcrSettings {
|
||||
&self.job.settings
|
||||
}
|
||||
|
||||
/// Prepare the next page for recognition. `Err` is a page that isn't read (it already has
|
||||
/// text, or couldn't be rendered or prepared), with the reason; `None` once every page is
|
||||
/// done.
|
||||
pub fn next_page(&mut self) -> Option<Result<OcrShot, OcrPage>> {
|
||||
loop {
|
||||
let page = *self.job.pages.get(self.next)?;
|
||||
self.next += 1;
|
||||
let Some(info) = self.job.infos.get(page).cloned() else { continue };
|
||||
return Some(self.prepare(page, info));
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare(&mut self, page: usize, info: PageInfo) -> Result<OcrShot, OcrPage> {
|
||||
let settings = &self.job.settings;
|
||||
let skip = |why: &str| OcrPage { page, words: Vec::new(), already_on_page: 0, skipped: Some(why.into()), notes: Vec::new() };
|
||||
let r = &mut self.renderer;
|
||||
// The boxes of all text on the page, in view space: an earlier OCR layer's, and words
|
||||
// only /ActualText names, so neither is read again.
|
||||
let existing: Vec<[f32; 4]> = r
|
||||
.render(RenderRequest { page, kind: RequestKind::Text, scale: 1.0, ..Default::default() })
|
||||
.text
|
||||
.map(|t| t.glyphs.iter().filter(|g| !g.text.trim().is_empty()).map(|g| g.rect).collect())
|
||||
.unwrap_or_default();
|
||||
if settings.skip_text_pages && !settings.force_ocr && !existing.is_empty() && has_own_text(r, page) {
|
||||
return Err(skip("the page already contains text"));
|
||||
}
|
||||
// A scan is read at its own resolution (enlarging it only blurs the letters), at
|
||||
// most the chosen one.
|
||||
let scale = self.job.render_scale(page);
|
||||
let shot = r.render(RenderRequest { page, scale, ..Default::default() });
|
||||
if let Some(e) = shot.error {
|
||||
return Err(skip(&e));
|
||||
}
|
||||
let full = pdfcraft_ocr::OcrImage::new(shot.width, shot.height, shot.rgba.to_vec()).map_err(|e| skip(&e.to_string()))?;
|
||||
// The optional region: crop the rendered page, clamped; an empty region skips the
|
||||
// page with a named reason (nothing was asked of it).
|
||||
let (image, offset) = match settings.region {
|
||||
None => (full, [0.0f32; 2]),
|
||||
Some(region) => match clamp_region(region, shot.width, shot.height) {
|
||||
Some([x0, y0, x1, y1]) => match crop(&full, x0, y0, x1, y1) {
|
||||
Some(c) => (c, [x0 as f32, y0 as f32]),
|
||||
None => return Err(skip("the selected region is empty")),
|
||||
},
|
||||
None => return Err(skip("the selected region is outside the page")),
|
||||
},
|
||||
};
|
||||
// Preprocessing sits between the page raster and recognition; its word boxes come
|
||||
// back in processed coordinates and are mapped to the ORIGINAL raster's before
|
||||
// placement. The render dpi is the real one (pixels per point × 72), never a
|
||||
// fixed value.
|
||||
let pre = pdfcraft_ocr::preprocess::preprocess(
|
||||
image,
|
||||
scale * 72.0,
|
||||
&pdfcraft_ocr::preprocess::PreprocessOptions {
|
||||
auto_rotate: settings.auto_rotate,
|
||||
deskew: settings.deskew,
|
||||
denoise: settings.denoise,
|
||||
binarize: settings.binarize,
|
||||
},
|
||||
)
|
||||
.map_err(|e| skip(&e.to_string()))?;
|
||||
Ok(OcrShot {
|
||||
page,
|
||||
image: Some(pre.image),
|
||||
inverse: pre.inverse_transform,
|
||||
offset,
|
||||
pixels_per_point: shot.width as f32 / info.width.max(1e-3),
|
||||
info,
|
||||
existing,
|
||||
strategy: settings.strategy,
|
||||
options: pdfcraft_ocr::RecognizeOptions { language: settings.language.clone() },
|
||||
output_mode: settings.output_mode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A page prepared for recognition: the image an engine reads, and what placing the words
|
||||
/// found in it needs.
|
||||
pub struct OcrShot {
|
||||
pub page: usize,
|
||||
/// The page's pixels as the engines read them: rendered, cropped to the region and
|
||||
/// preprocessed (`None` once [`OcrShot::take_image`] has handed them on).
|
||||
image: Option<OcrImage>,
|
||||
/// Processed pixels → the rendered raster's (the exact inverse of preprocessing).
|
||||
inverse: pdfcraft_ocr::preprocess::InverseTransform,
|
||||
/// Where the region crop starts in the rendered raster.
|
||||
offset: [f32; 2],
|
||||
/// The rendered raster's pixels per point of page view space.
|
||||
pixels_per_point: f32,
|
||||
info: PageInfo,
|
||||
/// The boxes of the page's own text, in view space.
|
||||
existing: Vec<[f32; 4]>,
|
||||
strategy: MergeStrategy,
|
||||
options: pdfcraft_ocr::RecognizeOptions,
|
||||
output_mode: OutputMode,
|
||||
}
|
||||
|
||||
impl OcrShot {
|
||||
/// What the engines read (`None` once taken).
|
||||
pub fn image(&self) -> Option<&OcrImage> {
|
||||
self.image.as_ref()
|
||||
}
|
||||
|
||||
/// Hand the image on (the web build sends it to a worker), keeping what placing the words
|
||||
/// needs: the page's pixels need not stay in memory while the worker reads them.
|
||||
pub fn take_image(&mut self) -> Option<OcrImage> {
|
||||
self.image.take()
|
||||
}
|
||||
|
||||
/// Recognise the image with `recognizers` (per the job's strategy) and place the words.
|
||||
pub fn read(&self, recognizers: &Recognizers) -> OcrPage {
|
||||
let Some(image) = &self.image else { return self.place(Err("the page's image was handed to another reader".into())) };
|
||||
let outcome = pdfcraft_ocr::recognize_with_strategy(
|
||||
recognizers.primary.as_ref(),
|
||||
recognizers.secondary.as_deref(),
|
||||
self.strategy,
|
||||
image,
|
||||
&self.options,
|
||||
);
|
||||
self.place(outcome.map_err(|e| e.to_string()))
|
||||
}
|
||||
|
||||
/// Put the lines recognised in [`Self::image`] on the page (or skip the page with the
|
||||
/// error). A single engine's reading, which is all the web build's workers run, is
|
||||
/// `StrategyOutcome { lines, ..Default::default() }`: every strategy reads that way when
|
||||
/// there is no second engine.
|
||||
pub fn place(&self, outcome: Result<StrategyOutcome, String>) -> OcrPage {
|
||||
let page = self.page;
|
||||
let outcome = match outcome {
|
||||
Ok(o) => o,
|
||||
Err(e) => return OcrPage { page, words: Vec::new(), already_on_page: 0, skipped: Some(e), notes: Vec::new() },
|
||||
};
|
||||
let (info, inverse, offset, scale) = (&self.info, self.inverse, self.offset, self.pixels_per_point);
|
||||
let to_user = |x: f32, y: f32| {
|
||||
// processed pixels → original raster pixels (the exact inverse) → past the
|
||||
// region crop → page view space.
|
||||
let (ox, oy) = inverse.apply(f64::from(x), f64::from(y));
|
||||
let [u, v] = info.view_to_user((ox as f32 + offset[0]) / scale, (oy as f32 + offset[1]) / scale);
|
||||
[u as f64, v as f64]
|
||||
};
|
||||
// Validate recognition geometry and compare against existing text in view space,
|
||||
// undoing preprocessing and region offsets before testing overlap.
|
||||
let (fresh, already): (Vec<_>, Vec<_>) = outcome.lines.iter().flat_map(|l| &l.words).filter(|w| box_writable(w)).partition(|w| {
|
||||
let [l, t, r, b] = w.rect;
|
||||
let bounds = [(l, t), (r, t), (r, b), (l, b)].into_iter().fold(
|
||||
[f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY],
|
||||
|mut bounds, (x, y)| {
|
||||
let (x, y) = inverse.apply(f64::from(x), f64::from(y));
|
||||
let x = (x as f32 + offset[0]) / scale;
|
||||
let y = (y as f32 + offset[1]) / scale;
|
||||
bounds[0] = bounds[0].min(x);
|
||||
bounds[1] = bounds[1].min(y);
|
||||
bounds[2] = bounds[2].max(x);
|
||||
bounds[3] = bounds[3].max(y);
|
||||
bounds
|
||||
},
|
||||
);
|
||||
covered(bounds, &self.existing) < ALREADY_ON_PAGE
|
||||
});
|
||||
let words = fresh.into_iter().map(|w| PlacedWord::place(w, to_user)).collect::<Vec<_>>();
|
||||
let mut notes = outcome.notes;
|
||||
// Only visible editable output needs display glyphs. Searchable output preserves
|
||||
// every Unicode character with empty Type3 programs and ToUnicode maps.
|
||||
if self.output_mode == OutputMode::EditableText && !words.is_empty() {
|
||||
let missing = pdfcraft_fonts::TextPlan::for_words(words.iter().map(|w| w.text.as_str())).missing();
|
||||
if missing > 0 {
|
||||
notes.push(format!("{missing} characters had no font that could show them and appear as ? in the output"));
|
||||
}
|
||||
}
|
||||
OcrPage { page, words, already_on_page: already.len(), skipped: None, notes }
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a word's pixel box may reach the content stream: width and height (right − left,
|
||||
@@ -1095,36 +1201,70 @@ pub fn recognize_file(
|
||||
recognizers: &Recognizers,
|
||||
progress: impl FnMut(usize, usize) -> bool,
|
||||
) -> Result<FileResult, String> {
|
||||
let mut s = Session::new();
|
||||
let id = s.open(name, None, bytes.clone(), password).map_err(|e| e.to_string())?;
|
||||
if let Some(why) = s.get(id).and_then(|d| d.read_only_reason.clone()) {
|
||||
return Err(why);
|
||||
match FileJob::open(name, bytes, password, settings)? {
|
||||
Opened::Skipped(result) => Ok(result),
|
||||
Opened::Read(mut file) => {
|
||||
let job = file.job.take().ok_or("the document could not be read")?;
|
||||
let pages = job.run(recognizers, progress);
|
||||
file.finish(pages)
|
||||
}
|
||||
}
|
||||
let job = s.ocr_job(id, &[], settings.clone()).ok_or("the document could not be read")?;
|
||||
// The skip-files probe runs before any engine work: a file with text needs no engine at
|
||||
// all, and the original bytes come back unchanged.
|
||||
if settings.skip_text_files && !settings.force_ocr && job.has_text() {
|
||||
let pages = job
|
||||
.pages
|
||||
.iter()
|
||||
.map(|&page| OcrPage {
|
||||
already_on_page: 0,
|
||||
page,
|
||||
words: Vec::new(),
|
||||
skipped: Some("the file already contains text".into()),
|
||||
notes: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
return Ok(FileResult { bytes, pages, file_skipped: Some("the file already contains text".into()) });
|
||||
}
|
||||
|
||||
/// [`recognize_file`] in two halves, for callers that read the pages themselves (the web build
|
||||
/// reads them in Web Workers): [`FileJob::open`], read `job`, then [`FileJob::finish`].
|
||||
pub struct FileJob {
|
||||
session: Session,
|
||||
id: DocId,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
/// The pages to read (taken by the caller).
|
||||
pub job: Option<OcrJob>,
|
||||
}
|
||||
|
||||
/// What [`FileJob::open`] found: a file to read, or one the skip-files option passes over
|
||||
/// (its result is final, and no engine is needed).
|
||||
pub enum Opened {
|
||||
Read(Box<FileJob>),
|
||||
Skipped(FileResult),
|
||||
}
|
||||
|
||||
impl FileJob {
|
||||
pub fn open(name: &str, bytes: Arc<Vec<u8>>, password: Option<&str>, settings: OcrSettings) -> Result<Opened, String> {
|
||||
let mut session = Session::new();
|
||||
let id = session.open(name, None, bytes.clone(), password).map_err(|e| e.to_string())?;
|
||||
if let Some(why) = session.get(id).and_then(|d| d.read_only_reason.clone()) {
|
||||
return Err(why);
|
||||
}
|
||||
let job = session.ocr_job(id, &[], settings.clone()).ok_or("the document could not be read")?;
|
||||
// The skip-files probe runs before any engine work: a file with text needs no engine at
|
||||
// all, and the original bytes come back unchanged.
|
||||
if settings.skip_text_files && !settings.force_ocr && job.has_text() {
|
||||
let pages = job
|
||||
.pages
|
||||
.iter()
|
||||
.map(|&page| OcrPage {
|
||||
already_on_page: 0,
|
||||
page,
|
||||
words: Vec::new(),
|
||||
skipped: Some("the file already contains text".into()),
|
||||
notes: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
return Ok(Opened::Skipped(FileResult { bytes, pages, file_skipped: Some("the file already contains text".into()) }));
|
||||
}
|
||||
Ok(Opened::Read(Box::new(FileJob { session, id, bytes, job: Some(job) })))
|
||||
}
|
||||
let pages = job.run(recognizers, progress);
|
||||
if s.apply_ocr(id, &pages).map_err(|e| e.to_string())? == 0 {
|
||||
return Ok(FileResult { bytes, pages, file_skipped: None });
|
||||
|
||||
/// Add the words found and save: the searchable file, or the original when nothing was found.
|
||||
pub fn finish(mut self, pages: Vec<OcrPage>) -> Result<FileResult, String> {
|
||||
if self.session.apply_ocr(self.id, &pages).map_err(|e| e.to_string())? == 0 {
|
||||
return Ok(FileResult { bytes: self.bytes, pages, file_skipped: None });
|
||||
}
|
||||
// Checked like a save before the caller writes it (#2166).
|
||||
let bytes = self.session.save_bytes(self.id).map_err(|e| e.to_string())?;
|
||||
self.session.check_save(self.id, &bytes).map_err(|e| e.to_string())?;
|
||||
Ok(FileResult { bytes, pages, file_skipped: None })
|
||||
}
|
||||
// Checked like a save before the caller writes it (#2166).
|
||||
let bytes = s.save_bytes(id).map_err(|e| e.to_string())?;
|
||||
s.check_save(id, &bytes).map_err(|e| e.to_string())?;
|
||||
Ok(FileResult { bytes, pages, file_skipped: None })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1639,6 +1779,87 @@ mod tests {
|
||||
assert_eq!(again.pages[0].skipped.as_deref(), Some("the page already contains text"));
|
||||
}
|
||||
|
||||
/// The web build prepares pages with [`OcrJob::steps`] and has a worker read each image,
|
||||
/// its lines coming back as JSON: the pages it ends with are the ones [`OcrJob::run`] gives,
|
||||
/// with a region crop and preprocessing between the render and the engine.
|
||||
#[test]
|
||||
fn reading_page_by_page_matches_a_run() {
|
||||
let mut s = Session::new();
|
||||
let bytes = s.create_from_text("t", "One page\nanother page").unwrap();
|
||||
let id = s.open("t.pdf", None, bytes, None).unwrap();
|
||||
let settings = OcrSettings { force_ocr: true, deskew: true, binarize: true, region: Some([40.0, 30.0, 900.0, 700.0]), ..Default::default() };
|
||||
let (r, fake) = fake_recognizers();
|
||||
let run = s.ocr_job(id, &[], settings.clone()).unwrap().run(&r, |_, _| true);
|
||||
assert!(run.iter().all(|p| p.skipped.is_none() && p.words.len() == 1), "{run:?}");
|
||||
let mut steps = s.ocr_job(id, &[], settings).unwrap().steps();
|
||||
assert_eq!((steps.done(), steps.total()), (0, run.len()));
|
||||
let mut stepped = Vec::new();
|
||||
while let Some(next) = steps.next_page() {
|
||||
let shot = next.unwrap();
|
||||
let json = pdfcraft_ocr::lines_to_json(&fake.recognize(shot.image().unwrap(), &pdfcraft_ocr::RecognizeOptions::default()).unwrap());
|
||||
let lines = lines_from_json(&json, "fake").map_err(|e| e.to_string());
|
||||
stepped.push(shot.place(lines.map(|lines| StrategyOutcome { lines, ..Default::default() })));
|
||||
}
|
||||
assert_eq!(steps.done(), run.len());
|
||||
assert_eq!(stepped, run);
|
||||
// An engine's failure skips the page with the reason, as in a run.
|
||||
let mut steps = s.ocr_job(id, &[0], OcrSettings { force_ocr: true, ..Default::default() }).unwrap().steps();
|
||||
let mut shot = steps.next_page().unwrap().unwrap();
|
||||
let page = shot.place(Err("worker gone".into()));
|
||||
assert_eq!(page.skipped.as_deref(), Some("worker gone"));
|
||||
// Once the image is handed on, it can't be read here as well.
|
||||
assert!(shot.take_image().is_some() && shot.image().is_none());
|
||||
assert!(shot.read(&r).skipped.is_some());
|
||||
assert!(steps.next_page().is_none());
|
||||
}
|
||||
|
||||
/// A page the job doesn't read comes out of [`OcrSteps::next_page`] as skipped, with its
|
||||
/// reason, before any engine is involved.
|
||||
#[test]
|
||||
fn page_by_page_skips_text_pages_without_an_engine() {
|
||||
let mut s = Session::new();
|
||||
let bytes = s.create_from_text("t", "A page with text").unwrap();
|
||||
let id = s.open("t.pdf", None, bytes, None).unwrap();
|
||||
let mut steps = s.ocr_job(id, &[], OcrSettings::default()).unwrap().steps();
|
||||
let Some(Err(page)) = steps.next_page() else { panic!("the text page should be skipped") };
|
||||
assert_eq!(page.skipped.as_deref(), Some("the page already contains text"));
|
||||
}
|
||||
|
||||
/// The web build has no model files: a recogniser loaded from downloaded bytes is installed
|
||||
/// and from then on is the engine every job reads with.
|
||||
#[test]
|
||||
fn an_installed_recognizer_is_available_and_handed_out() {
|
||||
let Some(models) = Models::find().filter(|m| m.detection.ends_with(DETECTION_MODEL)) else {
|
||||
eprintln!("skipped: ONNX OCR models not installed");
|
||||
return;
|
||||
};
|
||||
let ocr = Ocr::from_bytes(std::fs::read(&models.detection).unwrap(), std::fs::read(&models.recognition).unwrap()).unwrap();
|
||||
let installed = install(ocr);
|
||||
assert!(installed.available());
|
||||
assert!(available());
|
||||
let r = recognizers(&OcrSettings::default()).unwrap();
|
||||
assert!(Arc::ptr_eq(&r.primary, &installed), "the installed recogniser is the one handed out");
|
||||
assert!(engines_status().iter().any(|e| e.id == "ocrs" && e.available));
|
||||
}
|
||||
|
||||
/// The batch's halves: a file to read opens with its job, and [`FileJob::finish`] saves
|
||||
/// the searchable copy; a file the skip-files option passes over is final on opening.
|
||||
#[test]
|
||||
fn a_file_job_reads_in_two_halves() {
|
||||
let s = Session::new();
|
||||
let bytes = s.create_from_text("t", "Already typed here").unwrap();
|
||||
let (r, fake) = fake_recognizers();
|
||||
let skip = OcrSettings { skip_text_files: true, ..Default::default() };
|
||||
let Ok(Opened::Skipped(out)) = FileJob::open("t.pdf", bytes.clone(), None, skip) else { panic!("the file should be skipped") };
|
||||
assert_eq!(out.file_skipped.as_deref(), Some("the file already contains text"));
|
||||
let force = OcrSettings { force_ocr: true, ..Default::default() };
|
||||
let Ok(Opened::Read(mut file)) = FileJob::open("t.pdf", bytes.clone(), None, force.clone()) else { panic!("the file should be read") };
|
||||
let pages = file.job.take().unwrap().run(&r, |_, _| true);
|
||||
let out = file.finish(pages).unwrap();
|
||||
assert_eq!((fake.calls(), out.words(), out.file_skipped.clone()), (1, 1, None));
|
||||
assert_eq!(out, recognize_file("t.pdf", bytes, None, force, &r, |_, _| true).unwrap(), "the same as reading it in one call");
|
||||
}
|
||||
|
||||
/// Two inputs of one name (from different folders, or differing only in case) would
|
||||
/// write the same output: both are found, each once.
|
||||
#[test]
|
||||
|
||||
@@ -5681,6 +5681,35 @@ fn backgrounds_and_watermarks_from_files() {
|
||||
assert!(s.apply(id, Edit::AddBackground { pages: vec![0], settings: Background::default(), replace: true, file: Some(junk) }).is_err());
|
||||
}
|
||||
|
||||
/// The web build reads a page at a time (prepared on the page, recognised in a worker, the
|
||||
/// lines sent back as JSON); with the real ocrs engine that finds the same words as reading in
|
||||
/// one go (needs the models: `cargo xtask models`; skipped without them).
|
||||
#[test]
|
||||
fn reading_a_page_at_a_time_finds_the_same_words() {
|
||||
let settings = ocr::OcrSettings::default();
|
||||
let Ok(engines) = ocr::recognizers(&settings) else {
|
||||
eprintln!("skipped: OCR models not installed");
|
||||
return;
|
||||
};
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let text = s.create_from_text("t", "Invoice 42: pay by Friday").unwrap();
|
||||
let typed = s.open("text.pdf", None, text, None).unwrap();
|
||||
let png = export::Exporter::new(s.get(typed).unwrap()).png(0, 150.0).unwrap();
|
||||
let scan = s.create_from_images(&[("scan.png".into(), png.clone()), ("scan2.png".into(), png)]).unwrap();
|
||||
let id = s.open("scan.pdf", None, scan, None).unwrap();
|
||||
let at_once = s.ocr_job(id, &[], settings.clone()).unwrap().run(&engines, |_, _| true);
|
||||
let mut steps = s.ocr_job(id, &[], settings).unwrap().steps();
|
||||
let mut one_by_one = Vec::new();
|
||||
while let Some(next) = steps.next_page() {
|
||||
let shot = next.unwrap();
|
||||
let lines = engines.primary.recognize(shot.image().unwrap(), &pdfcraft_ocr::RecognizeOptions::default()).unwrap();
|
||||
let sent = ocr::lines_from_json(&pdfcraft_ocr::lines_to_json(&lines), engines.primary.id()).map_err(|e| e.to_string());
|
||||
one_by_one.push(shot.place(sent.map(|lines| ocr::StrategyOutcome { lines, ..Default::default() })));
|
||||
}
|
||||
assert_eq!(one_by_one, at_once);
|
||||
assert!(at_once.len() == 2 && at_once.iter().all(|p| p.skipped.is_none() && !p.words.is_empty()), "{at_once:?}");
|
||||
}
|
||||
|
||||
/// Scan & OCR ▸ Recognize text on a page that is only a picture of text (needs the models:
|
||||
/// `cargo xtask models`; skipped without them).
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user