diff --git a/Cargo.lock b/Cargo.lock index 9f10b97..96767b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2522,6 +2522,7 @@ dependencies = [ "http-body-util", "image", "object_store", + "ocr-service", "reqwest", "serde", "serde_json", diff --git a/Dockerfile.sandbox b/Dockerfile.sandbox index fbfdeff..2bc68ac 100644 --- a/Dockerfile.sandbox +++ b/Dockerfile.sandbox @@ -7,11 +7,12 @@ RUN apt-get update && apt-get install --no-install-recommends -y build-essential COPY Cargo.toml Cargo.lock ./ COPY crates ./crates COPY benchmarks ./benchmarks -RUN cargo build --locked --release -p ocr-sandbox +RUN cargo build --locked --release -p ocr-sandbox -p ocr-parser-sandbox FROM ${TESSERIX_RUNTIME} WORKDIR /app COPY LICENSE /licenses/document-intelligence.LICENSE COPY --from=build --chown=10001:10001 /src/target/release/ocr-sandbox /app/ocr-sandbox +COPY --from=build --chown=10001:10001 /src/target/release/ocr-parser-sandbox /app/ocr-parser-sandbox USER 10001:10001 LABEL org.opencontainers.image.source="https://github.com/tesserix/document-intelligence" \ org.opencontainers.image.licenses="Apache-2.0" diff --git a/crates/ocr-sandbox/Cargo.toml b/crates/ocr-sandbox/Cargo.toml index f15b78b..04c0aa9 100644 --- a/crates/ocr-sandbox/Cargo.toml +++ b/crates/ocr-sandbox/Cargo.toml @@ -14,6 +14,7 @@ futures-util.workspace = true hmac.workspace = true image.workspace = true object_store.workspace = true +ocr-service = { path = "../ocr-service" } reqwest.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/ocr-sandbox/src/api.rs b/crates/ocr-sandbox/src/api.rs index 795dc3e..da37044 100644 --- a/crates/ocr-sandbox/src/api.rs +++ b/crates/ocr-sandbox/src/api.rs @@ -25,6 +25,8 @@ pub struct App { pub(crate) storage: Storage, pub(crate) upstream: Upstream, permits: Semaphore, + pdf_permits: Semaphore, + parser: ocr_service::ParserProcess, } impl App { @@ -35,6 +37,12 @@ impl App { upstream: Upstream::new(&config)?, config, permits: Semaphore::new(8), + pdf_permits: Semaphore::new(1), + parser: ocr_service::ParserProcess::new( + "/app/ocr-parser-sandbox".into(), + std::time::Duration::from_secs(5), + ) + .map_err(|_| Error::Configuration)?, })) } @@ -163,8 +171,10 @@ struct UploadInput { fn validate_upload(body: &Value) -> Result { let input: UploadInput = serde_json::from_value(body.clone()).map_err(|_| Error::Invalid)?; let digest = input.sha256.strip_prefix("sha256:").ok_or(Error::Invalid)?; - if !matches!(input.content_type.as_str(), "image/jpeg" | "image/png") - || !(1..=MAX_BYTES).contains(&input.content_length) + if !matches!( + input.content_type.as_str(), + "application/pdf" | "image/jpeg" | "image/png" + ) || !(1..=MAX_BYTES).contains(&input.content_length) || digest.len() != 64 || !digest .bytes() @@ -255,6 +265,33 @@ fn validate_image(body: &[u8], input: &UploadInput) -> Result<(), Error> { Ok(()) } +async fn validate_pdf( + body: &[u8], + input: &UploadInput, + parser: &ocr_service::ParserProcess, +) -> Result<(), Error> { + if body.len() != input.content_length + || body.len() > MAX_BYTES + || format!("sha256:{}", hash(body)) != input.sha256 + || !body.starts_with(b"%PDF-") + { + return Err(Error::Invalid); + } + let report = parser + .inspect(body, "application/pdf") + .await + .map_err(|error| match error { + ocr_service::ParserProcessError::PasswordRequired => Error::PasswordProtected, + ocr_service::ParserProcessError::InvalidDocument + | ocr_service::ParserProcessError::LimitsExceeded => Error::InvalidDocument, + _ => Error::Unavailable, + })?; + if report.page_count != 1 || report.maximum_page_pixels > MAX_PIXELS as i64 { + return Err(Error::InvalidDocument); + } + Ok(()) +} + async fn upload_content( State(app): State>, Path(id): Path, @@ -265,9 +302,14 @@ async fn upload_content( owned(&session, &id, false)?; let input = validate_upload(session.upload_request.as_ref().ok_or(Error::Conflict)?)?; let bytes = body.clone(); - tokio::task::spawn_blocking(move || validate_image(&bytes, &input)) - .await - .map_err(|_| Error::Unavailable)??; + if input.content_type == "application/pdf" { + let _permit = app.pdf_permits.try_acquire().map_err(|_| Error::Limited)?; + validate_pdf(&bytes, &input, &app.parser).await?; + } else { + tokio::task::spawn_blocking(move || validate_image(&bytes, &input)) + .await + .map_err(|_| Error::Unavailable)??; + } if session.content_uploaded { return Ok(StatusCode::NO_CONTENT); } @@ -498,6 +540,52 @@ async fn job_result( #[cfg(test)] mod tests { use serde_json::json; + #[tokio::test] + async fn sandbox_pdf_validation_checks_bytes_and_one_page_budget() { + let path = std::env::current_exe() + .unwrap() + .parent() + .unwrap() + .parent() + .unwrap() + .join("ocr-parser-sandbox"); + let parser = + ocr_service::ParserProcess::new(path, std::time::Duration::from_secs(5)).unwrap(); + for (bytes, accepted) in [ + ( + include_bytes!("../tests/fixtures/ocr-upload-test.pdf").as_slice(), + true, + ), + ( + include_bytes!("../tests/fixtures/two-page.pdf").as_slice(), + false, + ), + ( + include_bytes!("../tests/fixtures/ocr-upload-test.png").as_slice(), + false, + ), + (b"%PDF-1.7\nnot a document".as_slice(), false), + ] { + let input = super::UploadInput { + content_type: "application/pdf".into(), + content_length: bytes.len(), + sha256: format!("sha256:{}", crate::store::hash(bytes)), + }; + assert_eq!( + super::validate_pdf(bytes, &input, &parser).await.is_ok(), + accepted + ); + } + } + #[test] + fn sandbox_accepts_pdf_upload_reservations() { + let digest = format!("sha256:{}", "a".repeat(64)); + assert!(super::validate_upload( + &json!({"content_type":"application/pdf","content_length":100,"sha256":digest}) + ) + .is_ok()); + } + #[test] fn sandbox_rejects_unknown_or_non_boolean_output_options() { for output in [ @@ -515,14 +603,14 @@ mod tests { } #[test] - fn sandbox_rejects_documents_outside_its_image_budget() { + fn sandbox_rejects_documents_outside_its_upload_budget() { let digest = format!("sha256:{}", "a".repeat(64)); assert!(super::validate_upload( &json!({"content_type":"image/png","content_length":100,"sha256":digest}) ) .is_ok()); for body in [ - json!({"content_type":"application/pdf","content_length":100,"sha256":digest}), + json!({"content_type":"image/tiff","content_length":100,"sha256":digest}), json!({"content_type":"image/png","content_length":4194305,"sha256":digest}), json!({"content_type":"image/png","content_length":100,"sha256":digest,"tenant_id":"ten_OTHER"}), ] { diff --git a/crates/ocr-sandbox/src/error.rs b/crates/ocr-sandbox/src/error.rs index e5a74b3..fe0ca19 100644 --- a/crates/ocr-sandbox/src/error.rs +++ b/crates/ocr-sandbox/src/error.rs @@ -13,6 +13,10 @@ pub enum Error { Unavailable, #[error("invalid request")] Invalid, + #[error("invalid document or document budget exceeded")] + InvalidDocument, + #[error("password-protected document")] + PasswordProtected, #[error("session expired or invalid")] Unauthorized, #[error("object not found")] @@ -34,6 +38,16 @@ impl From for Error { impl IntoResponse for Error { fn into_response(self) -> Response { let (status, code, message) = match self { + Self::InvalidDocument => ( + StatusCode::BAD_REQUEST, + "InvalidDocument", + "Choose a valid PDF with one page and at most 12 megapixels.", + ), + Self::PasswordProtected => ( + StatusCode::BAD_REQUEST, + "PasswordProtected", + "Password-protected PDFs cannot be processed in this sandbox.", + ), Self::Invalid => ( StatusCode::BAD_REQUEST, "InvalidRequest", @@ -57,7 +71,7 @@ impl IntoResponse for Error { Self::Conflict => ( StatusCode::CONFLICT, "SessionConflict", - "This session already has an image or a request in progress.", + "This session already has a document or a request in progress.", ), Self::Upstream(status) if status.is_client_error() => ( status, diff --git a/crates/ocr-sandbox/src/web.rs b/crates/ocr-sandbox/src/web.rs index 5d3c274..768c5b6 100644 --- a/crates/ocr-sandbox/src/web.rs +++ b/crates/ocr-sandbox/src/web.rs @@ -34,6 +34,15 @@ pub fn router() -> Router> { ) }), ) + .route( + "/document.mjs", + get(|| async { + ( + [("content-type", "text/javascript; charset=utf-8")], + include_str!("../web/document.mjs"), + ) + }), + ) .route( "/score.mjs", get(|| async { diff --git a/crates/ocr-sandbox/tests/fixtures/ocr-upload-test.jpeg b/crates/ocr-sandbox/tests/fixtures/ocr-upload-test.jpeg new file mode 100644 index 0000000..739713b Binary files /dev/null and b/crates/ocr-sandbox/tests/fixtures/ocr-upload-test.jpeg differ diff --git a/crates/ocr-sandbox/tests/fixtures/ocr-upload-test.pdf b/crates/ocr-sandbox/tests/fixtures/ocr-upload-test.pdf new file mode 100644 index 0000000..624cea4 Binary files /dev/null and b/crates/ocr-sandbox/tests/fixtures/ocr-upload-test.pdf differ diff --git a/crates/ocr-sandbox/tests/fixtures/ocr-upload-test.png b/crates/ocr-sandbox/tests/fixtures/ocr-upload-test.png new file mode 100644 index 0000000..b2e6f99 Binary files /dev/null and b/crates/ocr-sandbox/tests/fixtures/ocr-upload-test.png differ diff --git a/crates/ocr-sandbox/tests/fixtures/two-page.pdf b/crates/ocr-sandbox/tests/fixtures/two-page.pdf new file mode 100644 index 0000000..afab2f0 Binary files /dev/null and b/crates/ocr-sandbox/tests/fixtures/two-page.pdf differ diff --git a/crates/ocr-sandbox/web/app.mjs b/crates/ocr-sandbox/web/app.mjs index e01ccdd..f03b149 100644 --- a/crates/ocr-sandbox/web/app.mjs +++ b/crates/ocr-sandbox/web/app.mjs @@ -1,5 +1,6 @@ import {isTerminalJob,isFailedJob} from './status.mjs'; import {score} from './score.mjs'; +import {prepareFile} from './document.mjs'; const $=id=>document.getElementById(id); let selected=null,reference=null,previewUrl=null,lastResult=null,busy=false,runNumber=0; const pause=ms=>new Promise(resolve=>setTimeout(resolve,ms)); @@ -29,19 +30,24 @@ async function poll(path,token,states){ throw new Error('This document is taking longer than expected. The sandbox will finish or cancel the job and clean up its images automatically.'); } -function choose(file,sample=null){ +async function choose(file,sample=null){ $('error').hidden=true; - if(!['image/png','image/jpeg'].includes(file.type)||file.size>4194304||file.size===0){selected=null;$('run').disabled=true;error('Choose a PNG or JPEG image up to 4 MiB.');return;} - selected=file;reference=sample; + selected=null;reference=null;setBusy(true); if(previewUrl)URL.revokeObjectURL(previewUrl); - previewUrl=URL.createObjectURL(file);$('preview').src=previewUrl;$('preview-wrap').hidden=false; - $('file-title').textContent='Change selected image';$('file-hint').textContent='Choose another file or drop it here'; + previewUrl=null;$('preview-wrap').hidden=true; + try{file=await prepareFile(file);}catch(failure){error(failure.message);setBusy(false);return;} + selected=file;reference=sample; + const pdf=file.type==='application/pdf'; + $('preview').hidden=pdf;$('pdf-preview').hidden=!pdf; + if(!pdf){previewUrl=URL.createObjectURL(file);$('preview').src=previewUrl;} + $('preview-wrap').hidden=false; + $('file-title').textContent='Change selected document';$('file-hint').textContent='Choose another file or drop it here'; $('file-meta').textContent=`${file.name} · ${(file.size/1024).toFixed(0)} KiB${sample?' · benchmark sample':''}`; - $('nutrition').checked=Boolean(sample?.extraction);$('run').disabled=busy; + $('nutrition').checked=Boolean(sample?.extraction);setBusy(false); } $('dropzone').addEventListener('click',()=>$('file').click()); -$('file').addEventListener('change',event=>{if(event.target.files[0])choose(event.target.files[0]);}); +$('file').addEventListener('change',async event=>{if(event.target.files[0])await choose(event.target.files[0]);}); for(const name of ['dragover','dragleave','drop'])$('dropzone').addEventListener(name,event=>{event.preventDefault();if(busy)return;$('dropzone').classList.toggle('drag',name==='dragover');if(name==='drop'&&event.dataTransfer.files[0])choose(event.dataTransfer.files[0]);}); for(const button of document.querySelectorAll('[data-sample]'))button.addEventListener('click',async()=>{ setBusy(true); @@ -49,7 +55,7 @@ for(const button of document.querySelectorAll('[data-sample]'))button.addEventLi const manifest=await fetch('/samples/manifest.json',{cache:'no-store'}).then(r=>{if(!r.ok)throw new Error('Samples are unavailable.');return r.json();}); const sample=manifest.cases.find(item=>item.id===button.dataset.sample); const response=await fetch(`/samples/${sample.id}.png`);if(!response.ok)throw new Error('The sample image is unavailable.'); - choose(new File([await response.blob()],`${sample.id}.png`,{type:'image/png'}),sample); + await choose(new File([await response.blob()],`${sample.id}.png`,{type:'image/png'}),sample); }catch(failure){error(failure.message);}finally{setBusy(false);} }); @@ -59,7 +65,7 @@ function render(result,status,elapsed,benchmark,deleted){ if(benchmark?.kind==='fields'){$('benchmark').textContent=`${benchmark.correct} / ${benchmark.total}`;$('benchmark-note').textContent='Expected reference fields matched. A sample result is not a guarantee for other images.';} else if(benchmark?.kind==='text'){$('benchmark').textContent=`${(benchmark.characterErrorRate*100).toFixed(1)}% CER`;$('benchmark-note').textContent='Character error rate against the sample reference after whitespace normalization. Lower is better.';} else if(benchmark?.kind==='blank'){$('benchmark').textContent=benchmark.passed?'No invented text':'Unexpected text';$('benchmark-note').textContent='The reference image contains no readable text.';} - else{$('benchmark').textContent='Not scored';$('benchmark-note').textContent='Your image has no known reference. We do not infer an accuracy percentage.';} + else{$('benchmark').textContent='Not scored';$('benchmark-note').textContent='Your document has no known reference. We do not infer an accuracy percentage.';} cleanupStatus(deleted); $('text').textContent=result.text||'No readable text detected.';$('json').textContent=JSON.stringify(result,null,2); const warnings=[...(result.warnings??[]),...(result.validation_failures??[])]; @@ -72,7 +78,7 @@ function render(result,status,elapsed,benchmark,deleted){ } $('result-title').focus(); } -function cleanupStatus(deleted){$('cleanup').textContent=deleted?'✓ Server image copies deleted and absence verified.':'Image deletion pending. Server-side cleanup will retry automatically.';$('cleanup').classList.toggle('pending',!deleted);} +function cleanupStatus(deleted){$('cleanup').textContent=deleted?'✓ Server document copies deleted and absence verified.':'Document deletion pending. Server-side cleanup will retry automatically.';$('cleanup').classList.toggle('pending',!deleted);} $('run').addEventListener('click',async()=>{ if(busy||!selected)return; @@ -85,22 +91,22 @@ $('run').addEventListener('click',async()=>{ const digest=Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',await file.arrayBuffer())),n=>n.toString(16).padStart(2,'0')).join(''); const upload=(await request('/v1/ocr/uploads',{token,method:'POST',body:{content_type:file.type,content_length:file.size,sha256:`sha256:${digest}`}})).value; const uploadUrl=new URL(upload.upload_url);if(uploadUrl.origin!==location.origin)throw new Error('The sandbox returned an invalid upload destination.'); - stage('upload','Uploading your image…');await request(uploadUrl.pathname,{token,method:'PUT',body:file,headers:upload.required_headers}); + stage('upload','Uploading your document…');await request(uploadUrl.pathname,{token,method:'PUT',body:file,headers:upload.required_headers}); await request(`/v1/ocr/uploads/${upload.upload_id}/complete`,{token,method:'POST',body:{}}); - stage('inspect','Checking file safety and image structure…'); + stage('inspect','Checking file safety and document structure…'); const inspection=await poll(`/v1/ocr/uploads/${upload.upload_id}`,token,status=>['accepted','rejected','expired'].includes(status)); - if(inspection.status!=='accepted')throw new Error('The image did not pass inspection. Please try another image; cleanup will run automatically.'); + if(inspection.status!=='accepted')throw new Error('The document did not pass inspection. Please try another document; cleanup will run automatically.'); const payload={source:{upload_id:upload.upload_id},document_type:'general',output:{text:true,markdown:false,layout:true,evidence:true},language_hints:['en'],processing_class:'interactive'}; if($('nutrition').checked)payload.extraction={schema_id:'kora.nutrition_label',schema_version:'1'}; jobId=(await request('/v1/ocr/jobs',{token,method:'POST',body:payload})).value.job_id; - stage('ocr','Reading the image with the OCR service…');const state=await poll(`/v1/ocr/jobs/${jobId}`,token,isTerminalJob); - if(isFailedJob(state.status))throw new Error('OCR could not complete this image. Cleanup continues automatically.'); + stage('ocr','Reading the document with the OCR service…');const state=await poll(`/v1/ocr/jobs/${jobId}`,token,isTerminalJob); + if(isFailedJob(state.status))throw new Error('OCR could not complete this document. Cleanup continues automatically.'); const {value:result,response}=await request(`/v1/ocr/jobs/${jobId}/result`,{token}); const deleted=response.headers.get('x-sandbox-images-deleted')==='true'; render(result,state.status,performance.now()-start,score(sample?.sha256===digest?sample:null,result),deleted); stage('done','OCR finished. Inspect the output below.'); if(!deleted)void waitForCleanup(token,number); - }catch(failure){error(failure.message);$('result-state').textContent='Could not complete';$('status').textContent='You can try another image. Cleanup continues independently.';if(token&&jobId)try{await request(`/v1/ocr/jobs/${jobId}/cancel`,{token,method:'POST',body:{}});}catch{/* The durable cleanup worker retries cancellation. */}} + }catch(failure){error(failure.message);$('result-state').textContent='Could not complete';$('status').textContent='You can try another document. Cleanup continues independently.';if(token&&jobId)try{await request(`/v1/ocr/jobs/${jobId}/cancel`,{token,method:'POST',body:{}});}catch{/* The durable cleanup worker retries cancellation. */}} finally{setBusy(false);} }); diff --git a/crates/ocr-sandbox/web/app.test.mjs b/crates/ocr-sandbox/web/app.test.mjs index 648ecd8..5967049 100644 --- a/crates/ocr-sandbox/web/app.test.mjs +++ b/crates/ocr-sandbox/web/app.test.mjs @@ -31,8 +31,8 @@ test('browser stops polling rejected jobs and restores upload controls', async ( }; try { await import('./app.mjs'); - const file = new File(['synthetic image'], 'fixture.png', {type: 'image/png'}); - element('file').listeners.change({target: {files: [file]}}); + const file = new File([new Uint8Array([137,80,78,71,13,10,26,10])], 'fixture.png', {type: 'image/png'}); + await element('file').listeners.change({target: {files: [file]}}); await element('run').listeners.click(); assert.equal(polls, 1); assert.equal(results, 0); diff --git a/crates/ocr-sandbox/web/document.mjs b/crates/ocr-sandbox/web/document.mjs new file mode 100644 index 0000000..3430885 --- /dev/null +++ b/crates/ocr-sandbox/web/document.mjs @@ -0,0 +1,11 @@ +export async function prepareFile(file) { + const reject = () => { throw new Error('Choose a PDF, PNG or JPEG file up to 4 MiB.'); }; + if (file.size === 0 || file.size > 4194304) reject(); + const header = new Uint8Array(await file.slice(0, 12).arrayBuffer()); + const starts = bytes => bytes.every((value, index) => header[index] === value); + const type = starts([137,80,78,71,13,10,26,10]) ? 'image/png' + : starts([255,216,255]) ? 'image/jpeg' + : starts([37,80,68,70,45]) ? 'application/pdf' : null; + if (!type || (file.type && !['application/octet-stream',type,...(type === 'image/jpeg' ? ['image/jpg'] : [])].includes(file.type))) reject(); + return new File([file], file.name, {type, lastModified:file.lastModified}); +} diff --git a/crates/ocr-sandbox/web/document.test.mjs b/crates/ocr-sandbox/web/document.test.mjs new file mode 100644 index 0000000..65532fd --- /dev/null +++ b/crates/ocr-sandbox/web/document.test.mjs @@ -0,0 +1,22 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {prepareFile} from './document.mjs'; + +const png = new Uint8Array([137,80,78,71,13,10,26,10,0]); +const jpeg = new Uint8Array([255,216,255,224,0]); +const pdf = '%PDF-1.7\nfixture'; + +for (const [name,bytes,type] of [['sample.png',png,'image/png'],['sample.jpg',jpeg,'image/jpeg'],['sample.jpeg',jpeg,'image/jpeg'],['sample.pdf',pdf,'application/pdf']]) { + test(`selects ${name} when the operating system supplies no MIME type`, async () => { + const file = await prepareFile(new File([bytes],name)); + assert.equal(file.type,type); + assert.equal(file.name,name); + assert.equal(file.size,new Blob([bytes]).size); + }); +} + +test('rejects spoofed MIME types, unsupported content, empty and oversized files', async () => { + for (const file of [new File([pdf],'fake.png',{type:'image/png'}),new File([''],'fake.png'),new File([],'empty.pdf'),new File([new Uint8Array(4194305)],'large.png')]) { + await assert.rejects(prepareFile(file),/PDF, PNG or JPEG/); + } +}); diff --git a/crates/ocr-sandbox/web/index.html b/crates/ocr-sandbox/web/index.html index defc301..ac0c25e 100644 --- a/crates/ocr-sandbox/web/index.html +++ b/crates/ocr-sandbox/web/index.html @@ -2,25 +2,25 @@ - + OCR Sandbox · Tesserix
tesserix / OCR sandboxSource & docs ↗
-

REAL OCR. SMALL, SHARED SANDBOX.

From image
to something readable.

See what our OCR service can read. Upload an image or try a known sample, then inspect the text, fields and benchmark results.

OCR onlyNo chat or generative AIAutomatic image cleanup
+

REAL OCR. SMALL, SHARED SANDBOX.

From document
to something readable.

See what our OCR service can read. Upload a PDF, JPEG or PNG file, or try a known sample, then inspect the text, fields and benchmark results.

OCR onlyNo chat or generative AIAutomatic image cleanup
-
01

Choose an image

PNG / JPG
-
Image controls - - +
01

Choose a document

PDF / PNG / JPEG
+
Document controls + +

Or start with a benchmark

Known reference
-

Please use non-sensitive test images. Server copies are deleted after OCR finishes; failed cleanup is retried. Your local file and browser preview remain yours. How it works ↓

+

Please use non-sensitive test documents. Server document copies are deleted after OCR finishes; failed cleanup is retried. Your local file and browser preview remain yours. How it works ↓

02

Inspect the result

Ready when you are
@@ -33,6 +33,6 @@
-

BUILT FOR A QUICK CHECK

Your image is a test,
not a training dataset.

This sandbox calls the same OCR upload, inspection and processing APIs used by the service. It adds no chat model, JEV or generative extraction. OCR processing itself uses Google Document AI.

Images are removed from dedicated sandbox storage once processing reaches a terminal state. Results and page artifacts expire with the session. Cleanup keeps running if you close the page; outages can delay deletion, and the UI reports it as pending. Minimal job and quota metadata is retained separately. Provider processing is subject to its own data policy.

TRY THE API, TOO

Same journey.
Your own client.

Start a short-lived sandbox session, then reserve an upload, send the image, create a job and fetch its result. The session token stays in your browser memory and expires after 15 minutes.

View the API walkthrough ↗

One image per session. Shared daily upload budget. Session and request limits apply. This is a capability demo, not a production endpoint.

+

BUILT FOR A QUICK CHECK

Your document is a test,
not a training dataset.

This sandbox calls the same OCR upload, inspection and processing APIs used by the service. It adds no chat model, JEV or generative extraction. OCR processing itself uses Google Document AI.

Documents are removed from dedicated sandbox storage once processing reaches a terminal state. Results and page artifacts expire with the session. Cleanup keeps running if you close the page; outages can delay deletion, and the UI reports it as pending. Minimal job and quota metadata is retained separately. Provider processing is subject to its own data policy.

TRY THE API, TOO

Same journey.
Your own client.

Start a short-lived sandbox session, then reserve an upload, send the document, create a job and fetch its result. The session token stays in your browser memory and expires after 15 minutes.

View the API walkthrough ↗

One document per session. Shared daily upload budget. Session and request limits apply. This is a capability demo, not a production endpoint.

diff --git a/crates/ocr-sandbox/web/upload.test.mjs b/crates/ocr-sandbox/web/upload.test.mjs new file mode 100644 index 0000000..8a2e6c8 --- /dev/null +++ b/crates/ocr-sandbox/web/upload.test.mjs @@ -0,0 +1,9 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFile} from 'node:fs/promises'; + +test('native file picker includes PDF, PNG and both JPEG extensions', async () => { + const html = await readFile(new URL('./index.html', import.meta.url), 'utf8'); + const accept = html.match(/id="file"[^>]*accept="([^"]+)"/)[1].split(','); + for (const extension of ['.pdf', '.png', '.jpg', '.jpeg']) assert.ok(accept.includes(extension), extension); +}); diff --git a/docs/OCR-SANDBOX.md b/docs/OCR-SANDBOX.md index 5dcdcbf..1facc87 100644 --- a/docs/OCR-SANDBOX.md +++ b/docs/OCR-SANDBOX.md @@ -12,7 +12,7 @@ the examples below. ## What you can test -- Upload one JPEG or PNG (at most 4 MiB and 12 megapixels). +- Upload one PDF, JPEG or PNG document (at most 4 MiB, one page and 12 megapixels). - Read extracted text, warnings, provider confidence, and optional nutrition fields. - Download the result JSON. - Compare the bundled nutrition label, receipt and blank-image samples against diff --git a/docs/adr/0015-public-ocr-sandbox.md b/docs/adr/0015-public-ocr-sandbox.md index 49b43cd..1cc6d1d 100644 --- a/docs/adr/0015-public-ocr-sandbox.md +++ b/docs/adr/0015-public-ocr-sandbox.md @@ -7,7 +7,7 @@ Serve a small static UI and a server-side API adapter from one GKE service at perform durable cleanup. The adapter calls the existing OCR upload/job APIs; it never calls a chat model, JEV or a generative extraction service. -Initial limits: JPEG/PNG only, one image per session, 4 MiB and 12 megapixels, +Initial limits: PDF/JPEG/PNG, one document and one page per session, 4 MiB and 12 megapixels, ten sessions per network per hour, 100 uploads per UTC day across the deployment, 60 API requests per session per minute, and a 15-minute session lifetime. At 100 images/day this is at most 400 MiB/day of original uploads before deletion; @@ -34,6 +34,8 @@ The assets are visitor documents, API signing credentials and the OCR budget. Threats include anonymous automation, cross-session access and hostile files. The browser-to-adapter, adapter-to-OCR and adapter-to-storage boundaries validate input separately. The private OCR service retains its malware/parser checks. +PDF intake reuses the existing parser in a separate process with a five-second deadline; its environment is cleared and only one PDF inspection runs at a time. Require exactly one page and at most 12 megapixels before forwarding content, preserving the 100-page/day budget. Malformed, encrypted and over-budget PDFs fail closed; parser unavailability returns 503. The one-page cap avoids an increase in provider spend. No service, database, migration or retention policy is added. Rollback restores the earlier adapter image through GitOps; cleanup stays running. + No session token, signed URL, image or OCR text is logged. The frontend renders text as text, never HTML. No localStorage, analytics or third-party UI scripts.