use std::io::Cursor;
use std::sync::Arc;
use image::ImageFormat;
use rapid_structure_rs::{
LoadImage, OcrInput, OcrOutput, OcrTimings, RapidOcrError, ReportMode, TextOrder, TimingLedger,
draw_output, render_output_report, to_output_json, to_output_markdown,
};
use serde_json::{Value, json};
use super::results::{SerializeError, serialize_bounded, text_bounded};
fn decode_limits() -> (u64, u64) {
let policy = rapid_structure_rs::PreprocessPolicy::default();
(policy.max_decode_pixels, policy.max_encoded_bytes)
}
#[derive(Debug)]
pub(super) enum ExportError {
TooLarge { observed_bytes: u64 },
Decode(RapidOcrError),
Render(String),
Internal(String),
}
impl From<RapidOcrError> for ExportError {
fn from(error: RapidOcrError) -> Self {
Self::Decode(error)
}
}
impl From<SerializeError> for ExportError {
fn from(error: SerializeError) -> Self {
match error {
SerializeError::TooLarge => Self::TooLarge { observed_bytes: 0 },
SerializeError::Internal(reason) => Self::Internal(reason),
}
}
}
pub(super) fn decode_original(bytes: &[u8]) -> Result<rapid_structure_rs::RecImage, ExportError> {
let (max_pixels, max_encoded) = decode_limits();
let image = LoadImage::default().load_with_limit(
OcrInput::Bytes(bytes.to_vec()),
max_pixels,
max_encoded,
)?;
Ok(image)
}
pub(super) fn annotated_png(
original: Arc<[u8]>,
output: &OcrOutput,
) -> Result<Vec<u8>, ExportError> {
let image = decode_original(&original)?;
let annotated = draw_output(&image, output);
let mut png = Vec::new();
annotated
.write_to(&mut Cursor::new(&mut png), ImageFormat::Png)
.map_err(|error| ExportError::Render(error.to_string()))?;
Ok(png)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ExportFormat {
Json,
Markdown,
Html,
}
impl ExportFormat {
pub const fn extension(self) -> &'static str {
match self {
Self::Json => "json",
Self::Markdown => "md",
Self::Html => "html",
}
}
pub const fn content_type(self) -> &'static str {
match self {
Self::Json => EXPORT_JSON_CONTENT_TYPE,
Self::Markdown => EXPORT_MARKDOWN_CONTENT_TYPE,
Self::Html => EXPORT_HTML_CONTENT_TYPE,
}
}
}
pub(super) fn png_data_url(png: &[u8]) -> String {
let mut url = String::with_capacity(BASE64_PREFIX.len() + base64_len(png.len()));
url.push_str(BASE64_PREFIX);
base64_encode_into(png, &mut url);
url
}
pub(super) const BASE64_PREFIX: &str = "data:image/png;base64,";
fn base64_len(bytes: usize) -> usize {
bytes.div_ceil(3) * 4
}
fn base64_encode_into(bytes: &[u8], out: &mut String) {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let chunks = bytes.len() / 3;
let remainder = bytes.len() % 3;
out.reserve(base64_len(bytes.len()) + 3);
for chunk in bytes[..chunks * 3].as_chunks::<3>().0 {
let packed = (u32::from(chunk[0]) << 16) | (u32::from(chunk[1]) << 8) | u32::from(chunk[2]);
out.push(ALPHABET[(packed >> 18) as usize & 0x3f] as char);
out.push(ALPHABET[(packed >> 12) as usize & 0x3f] as char);
out.push(ALPHABET[(packed >> 6) as usize & 0x3f] as char);
out.push(ALPHABET[packed as usize & 0x3f] as char);
}
let tail = &bytes[chunks * 3..];
match remainder {
1 => {
let packed = u32::from(tail[0]) << 16;
out.push(ALPHABET[(packed >> 18) as usize & 0x3f] as char);
out.push(ALPHABET[(packed >> 12) as usize & 0x3f] as char);
out.push('=');
out.push('=');
}
2 => {
let packed = (u32::from(tail[0]) << 16) | (u32::from(tail[1]) << 8);
out.push(ALPHABET[(packed >> 18) as usize & 0x3f] as char);
out.push(ALPHABET[(packed >> 12) as usize & 0x3f] as char);
out.push(ALPHABET[(packed >> 6) as usize & 0x3f] as char);
out.push('=');
}
_ => {}
}
}
pub(super) fn timing_ledger_json(timings: &OcrTimings) -> Value {
let ledger = TimingLedger::from_timings(timings);
let mut value = serde_json::to_value(ledger).expect("TimingLedger serialization cannot fail");
let Value::Object(object) = &mut value else {
return Value::Null;
};
object.insert("attributed_ms".to_string(), json!(ledger.attributed_ms()));
object.insert("input_ms".to_string(), json!(ledger.input_ms()));
object.insert("inference_ms".to_string(), json!(ledger.inference_ms()));
object.insert("rust_ms".to_string(), json!(ledger.rust_ms()));
object.insert(
"shares".to_string(),
match ledger.shares() {
Some(shares) => serde_json::to_value(shares).expect("LedgerShares serializes"),
None => Value::Null,
},
);
object.insert(
"conservation".to_string(),
serde_json::to_value(ledger.conservation()).expect("LedgerConservation serializes"),
);
value
}
pub(super) fn result_json(output: &OcrOutput) -> Result<Value, RapidOcrError> {
let mut value = to_output_json(output)?;
if let Value::Object(object) = &mut value {
object.insert(
"plain_text".to_string(),
Value::String(output.plain_text(TextOrder::Reading)),
);
object.insert(
"timing_ledger".to_string(),
timing_ledger_json(&output.timings),
);
}
Ok(value)
}
pub(super) struct ExportRequest<'a> {
pub output: &'a OcrOutput,
pub png: &'a [u8],
pub title: &'a str,
pub limit_bytes: u64,
}
pub(super) const EXPORT_JSON_CONTENT_TYPE: &str = "application/json; charset=utf-8";
pub(super) const EXPORT_MARKDOWN_CONTENT_TYPE: &str = "text/markdown; charset=utf-8";
pub(super) const EXPORT_HTML_CONTENT_TYPE: &str = "text/html; charset=utf-8";
pub(super) fn html_document(request: &ExportRequest<'_>) -> Result<Vec<u8>, ExportError> {
let timing = timing_summary(request.output);
let data_url = png_data_url(request.png);
let body = render_output_report(
request.title,
"",
request.output,
&timing,
ReportMode::Static,
)
.map_err(|error| ExportError::Render(error.to_string()))?;
let projected = (body.len() as u64).saturating_add(data_url.len() as u64);
if projected > request.limit_bytes {
return Err(ExportError::TooLarge {
observed_bytes: projected,
});
}
let document = render_output_report(
request.title,
&data_url,
request.output,
&timing,
ReportMode::Static,
)
.map_err(|error| ExportError::Render(error.to_string()))?;
match text_bounded(&document, request.limit_bytes) {
Ok(bytes) => Ok(bytes),
Err(SerializeError::TooLarge) => Err(ExportError::TooLarge {
observed_bytes: document.len() as u64,
}),
Err(error) => Err(ExportError::from(error)),
}
}
pub(super) fn markdown_document(request: &ExportRequest<'_>) -> Result<Vec<u8>, ExportError> {
let document = to_output_markdown(request.output);
let observed = document.len() as u64;
match text_bounded(&document, request.limit_bytes) {
Ok(bytes) => Ok(bytes),
Err(SerializeError::TooLarge) => Err(ExportError::TooLarge {
observed_bytes: observed,
}),
Err(error) => Err(ExportError::from(error)),
}
}
pub(super) fn json_document(
output: &OcrOutput,
limit_bytes: u64,
measured_bytes: u64,
) -> Result<Vec<u8>, ExportError> {
let value = result_json(output).map_err(|error| ExportError::Render(error.to_string()))?;
match serialize_bounded(&value, limit_bytes) {
Ok(bytes) => Ok(bytes),
Err(SerializeError::TooLarge) => Err(ExportError::TooLarge {
observed_bytes: measured_bytes,
}),
Err(error) => Err(ExportError::from(error)),
}
}
pub(super) fn timing_summary(output: &OcrOutput) -> String {
format!("total {:.1} ms", output.timings.total_ms)
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::{
BASE64_PREFIX, ExportError, ExportRequest, base64_encode_into, base64_len, html_document,
markdown_document, png_data_url, timing_ledger_json, timing_summary,
};
use crate::serve::tests::scripted_output;
fn encode(bytes: &[u8]) -> String {
let mut out = String::new();
base64_encode_into(bytes, &mut out);
out
}
#[test]
fn base64_matches_the_rfc4648_vectors_at_every_length() {
assert_eq!(encode(b""), "");
assert_eq!(encode(b"f"), "Zg==");
assert_eq!(encode(b"fo"), "Zm8=");
assert_eq!(encode(b"foo"), "Zm9v");
assert_eq!(encode(b"foob"), "Zm9vYg==");
assert_eq!(encode(b"fooba"), "Zm9vYmE=");
assert_eq!(encode(b"foobar"), "Zm9vYmFy");
assert_eq!(encode(&[0x00]), "AA==");
assert_eq!(encode(&[0xff]), "/w==");
assert_eq!(encode(&[0xff, 0xff]), "//8=");
assert_eq!(encode(&[0xff, 0xff, 0xff]), "////");
for len in 0..64 {
let bytes: Vec<u8> = (0..len).map(|index| index as u8).collect();
assert_eq!(encode(&bytes).len(), base64_len(len), "len={len}");
}
}
#[test]
fn a_png_becomes_a_data_url_that_the_export_csp_allows() {
let url = png_data_url(&[0x89, b'P', b'N', b'G']);
assert!(url.starts_with(BASE64_PREFIX), "{url}");
assert_eq!(url, "data:image/png;base64,iVBORw==");
}
#[test]
fn the_timing_ledger_carries_its_own_interpretation() {
let output = scripted_output(2, 0);
let value = timing_ledger_json(&output.timings);
assert!(value["total_ms"].is_number(), "{value}");
assert!(value["detector_infer_ms"].is_number(), "{value}");
assert!(value["attributed_ms"].is_number(), "{value}");
assert!(value["rust_ms"].is_number(), "{value}");
assert!(value["conservation"]["residual_ms"].is_number(), "{value}");
assert!(value["conservation"]["excess_ms"].is_number(), "{value}");
let interpretation = value["conservation"]["interpretation"]
.as_str()
.expect("the ledger must explain itself");
assert!(
interpretation.contains("strict partition"),
"the interpretation must say what the residual is not: {interpretation}"
);
assert!(timing_summary(&output).starts_with("total "));
}
#[test]
fn html_export_is_static_and_refuses_to_overshoot() {
let output = scripted_output(2, 32);
let png = vec![0x89, b'P', b'N', b'G', 1, 2, 3, 4];
let request = ExportRequest {
output: &output,
png: &png,
title: "ocr-job-0",
limit_bytes: 1 << 20,
};
let document = String::from_utf8(html_document(&request).expect("fits")).expect("utf-8");
assert!(!document.to_lowercase().contains("<script"), "{document}");
assert!(document.contains(BASE64_PREFIX), "{document}");
assert!(document.contains("ocr-job-0"), "{document}");
assert!(document.contains("<style>"), "{document}");
let tight = ExportRequest {
limit_bytes: 16,
..request
};
match html_document(&tight).expect_err("must refuse") {
ExportError::TooLarge { observed_bytes } => {
assert!(observed_bytes > 16, "{observed_bytes}");
}
other => panic!("expected TooLarge, got {other:?}"),
}
}
#[test]
fn markdown_export_is_bounded_and_carries_the_text() {
let output = scripted_output(1, 0);
let request = ExportRequest {
output: &output,
png: &[],
title: "ocr-job-0",
limit_bytes: 1 << 20,
};
let document =
String::from_utf8(markdown_document(&request).expect("fits")).expect("utf-8");
assert!(document.contains("region-0"), "{document}");
match markdown_document(&ExportRequest {
limit_bytes: 4,
..request
})
.expect_err("must refuse")
{
ExportError::TooLarge { observed_bytes } => assert!(observed_bytes > 4),
other => panic!("expected TooLarge, got {other:?}"),
}
}
#[test]
fn the_annotated_png_keeps_the_original_dimensions() {
let source = synthetic_png(37, 19);
let output = scripted_output(1, 0);
let png = super::annotated_png(Arc::from(source.into_boxed_slice()), &output)
.expect("the synthetic image decodes");
assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n", "PNG signature");
let decoded = image::load_from_memory(&png).expect("the annotation is a valid image");
assert_eq!((decoded.width(), decoded.height()), (37, 19));
}
pub(super) fn synthetic_png(width: u32, height: u32) -> Vec<u8> {
let image =
image::RgbImage::from_fn(width, height, |x, _| image::Rgb([(x % 256) as u8, 32, 64]));
let mut bytes = Vec::new();
image
.write_to(
&mut std::io::Cursor::new(&mut bytes),
image::ImageFormat::Png,
)
.expect("the synthetic PNG encodes");
bytes
}
}