use serde::{Deserialize, Deserializer, Serialize};
use std::sync::Arc;
use super::extraction::BoundingBox;
use super::serde_helpers::serde_vec_arc;
use super::tables::Table;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct PageStructure {
pub total_count: u32,
pub unit_type: PageUnitType,
#[serde(skip_serializing_if = "Option::is_none")]
pub boundaries: Option<Vec<PageBoundary>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pages: Option<Vec<PageInfo>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub enum PageUnitType {
Page,
Slide,
Sheet,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct PageBoundary {
pub byte_start: usize,
pub byte_end: usize,
pub page_number: u32,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize)]
pub struct PageDimensions {
pub width: f64,
pub height: f64,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum PageDimensionsWire {
Positional((f64, f64)),
Named { width: f64, height: f64 },
}
impl<'de> Deserialize<'de> for PageDimensions {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
Ok(match PageDimensionsWire::deserialize(deserializer)? {
PageDimensionsWire::Positional(dimensions) => dimensions.into(),
PageDimensionsWire::Named { width, height } => Self { width, height },
})
}
}
#[cfg(feature = "api")]
impl utoipa::PartialSchema for PageDimensions {
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
use utoipa::openapi::schema::{Object, ObjectBuilder, Type};
ObjectBuilder::new()
.property("width", Object::with_type(Type::Number))
.required("width")
.property("height", Object::with_type(Type::Number))
.required("height")
.into()
}
}
#[cfg(feature = "api")]
impl utoipa::ToSchema for PageDimensions {}
impl From<(f64, f64)> for PageDimensions {
fn from((width, height): (f64, f64)) -> Self {
Self { width, height }
}
}
impl From<PageDimensions> for (f64, f64) {
fn from(dimensions: PageDimensions) -> Self {
(dimensions.width, dimensions.height)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct PageInfo {
pub number: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dimensions: Option<PageDimensions>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub table_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hidden: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_blank: Option<bool>,
#[serde(default, skip_serializing_if = "is_default_bool")]
pub has_vector_graphics: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct PageContent {
pub page_number: u32,
pub content: String,
#[serde(skip_serializing_if = "Vec::is_empty", default, with = "serde_vec_arc")]
#[cfg_attr(feature = "api", schema(value_type = Vec<Table>))]
pub tables: Vec<Arc<Table>>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub image_indices: Vec<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_preprocessing: Option<super::ImagePreprocessingMetadata>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hierarchy: Option<PageHierarchy>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_blank: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub layout_regions: Option<Vec<LayoutRegion>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub speaker_notes: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub section_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sheet_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ocr_confidence: Option<PageOcrConfidence>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct PageOcrConfidence {
pub score: Option<f64>,
pub word_count: u32,
pub backend: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct LayoutRegion {
#[serde(alias = "class")]
pub class_name: String,
pub confidence: f64,
pub bounding_box: BoundingBox,
pub area_fraction: f64,
}
impl LayoutRegion {
#[deprecated(since = "1.1.0", note = "Use `class_name` field instead")]
pub fn class(&self) -> &str {
&self.class_name
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct PageHierarchy {
pub block_count: u32,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub blocks: Vec<HierarchicalBlock>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize)]
pub struct HierarchicalBoundingBox {
pub left: f32,
pub top: f32,
pub right: f32,
pub bottom: f32,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum HierarchicalBoundingBoxWire {
Positional((f32, f32, f32, f32)),
Named {
left: f32,
top: f32,
right: f32,
bottom: f32,
},
}
impl<'de> Deserialize<'de> for HierarchicalBoundingBox {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
Ok(match HierarchicalBoundingBoxWire::deserialize(deserializer)? {
HierarchicalBoundingBoxWire::Positional(bbox) => bbox.into(),
HierarchicalBoundingBoxWire::Named {
left,
top,
right,
bottom,
} => Self {
left,
top,
right,
bottom,
},
})
}
}
#[cfg(feature = "api")]
impl utoipa::PartialSchema for HierarchicalBoundingBox {
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
use utoipa::openapi::schema::{Object, ObjectBuilder, Type};
ObjectBuilder::new()
.property("left", Object::with_type(Type::Number))
.required("left")
.property("top", Object::with_type(Type::Number))
.required("top")
.property("right", Object::with_type(Type::Number))
.required("right")
.property("bottom", Object::with_type(Type::Number))
.required("bottom")
.into()
}
}
#[cfg(feature = "api")]
impl utoipa::ToSchema for HierarchicalBoundingBox {}
impl From<(f32, f32, f32, f32)> for HierarchicalBoundingBox {
fn from((left, top, right, bottom): (f32, f32, f32, f32)) -> Self {
Self {
left,
top,
right,
bottom,
}
}
}
impl From<HierarchicalBoundingBox> for (f32, f32, f32, f32) {
fn from(bbox: HierarchicalBoundingBox) -> Self {
(bbox.left, bbox.top, bbox.right, bbox.bottom)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct HierarchicalBlock {
pub text: String,
pub font_size: f32,
pub level: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub bbox: Option<HierarchicalBoundingBox>,
}
fn is_default_bool(v: &bool) -> bool {
!*v
}
#[cfg(test)]
mod binding_value_serde_tests {
use super::{HierarchicalBlock, HierarchicalBoundingBox, PageContent, PageDimensions, PageInfo, PageOcrConfidence};
use serde_json::json;
#[cfg(feature = "api")]
fn assert_named_object_schema<T: utoipa::PartialSchema>(fields: &[&str]) {
let schema = serde_json::to_value(T::schema()).expect("schema must serialize");
assert_eq!(schema["type"], "object");
let properties = schema["properties"].as_object().expect("schema must have properties");
assert_eq!(properties.len(), fields.len());
for field in fields {
assert_eq!(properties[*field]["type"], "number");
}
let required = schema["required"].as_array().expect("schema must have required fields");
assert_eq!(required.len(), fields.len());
for field in fields {
assert!(required.contains(&json!(field)));
}
}
#[cfg(feature = "api")]
#[test]
fn should_describe_binding_dtos_as_named_object_schemas() {
assert_named_object_schema::<PageDimensions>(&["width", "height"]);
assert_named_object_schema::<HierarchicalBoundingBox>(&["left", "top", "right", "bottom"]);
}
#[test]
fn should_still_accept_legacy_positional_array_for_page_dimensions() {
let legacy = json!({
"number": 1,
"dimensions": [612.0, 792.0]
});
let page: PageInfo = serde_json::from_value(legacy).expect("legacy page info must deserialize");
let dimensions = page.dimensions.expect("dimensions must be present");
assert_eq!(dimensions.width, 612.0);
assert_eq!(dimensions.height, 792.0);
}
#[test]
fn should_serialize_page_dimensions_as_named_object() {
let named: PageDimensions = serde_json::from_value(json!({"width": 612.0, "height": 792.0}))
.expect("named page dimensions must deserialize");
assert_eq!(
named,
PageDimensions {
width: 612.0,
height: 792.0
}
);
assert_eq!(
serde_json::to_value(named).expect("named page dimensions must serialize"),
json!({"width": 612.0, "height": 792.0})
);
let page = PageInfo {
number: 1,
title: None,
dimensions: Some(named),
image_count: None,
table_count: None,
hidden: None,
is_blank: None,
has_vector_graphics: false,
};
assert_eq!(
serde_json::to_value(page).expect("page info must serialize"),
json!({"number": 1, "dimensions": {"width": 612.0, "height": 792.0}})
);
}
#[test]
fn should_accept_page_info_without_dimensions() {
let page: PageInfo = serde_json::from_value(json!({
"number": 2
}))
.expect("page info without dimensions must deserialize");
assert!(page.dimensions.is_none());
}
#[test]
fn should_still_accept_legacy_positional_array_for_hierarchical_bounding_box() {
let legacy = json!({
"text": "Heading",
"font_size": 18.0,
"level": "h1",
"bbox": [1.0, 2.0, 101.0, 22.0]
});
let block: HierarchicalBlock = serde_json::from_value(legacy).expect("legacy hierarchy block must deserialize");
let bbox = block.bbox.expect("bounding box must be present");
assert_eq!((bbox.left, bbox.top, bbox.right, bbox.bottom), (1.0, 2.0, 101.0, 22.0));
}
#[test]
fn should_serialize_hierarchical_bounding_box_as_named_object() {
let named: HierarchicalBoundingBox = serde_json::from_value(json!({
"left": 1.0,
"top": 2.0,
"right": 101.0,
"bottom": 22.0
}))
.expect("named hierarchy bounding box must deserialize");
assert_eq!(
named,
HierarchicalBoundingBox {
left: 1.0,
top: 2.0,
right: 101.0,
bottom: 22.0
}
);
assert_eq!(
serde_json::to_value(named).expect("named hierarchy bounding box must serialize"),
json!({"left": 1.0, "top": 2.0, "right": 101.0, "bottom": 22.0})
);
let block = HierarchicalBlock {
text: "Heading".to_string(),
font_size: 18.0,
level: "h1".to_string(),
bbox: Some(named),
};
assert_eq!(
serde_json::to_value(block).expect("hierarchy block must serialize"),
json!({
"text": "Heading",
"font_size": 18.0,
"level": "h1",
"bbox": {"left": 1.0, "top": 2.0, "right": 101.0, "bottom": 22.0}
})
);
}
fn page_content_without_ocr_confidence() -> PageContent {
PageContent {
page_number: 1,
content: "hello".to_string(),
tables: Vec::new(),
image_indices: Vec::new(),
image_preprocessing: None,
hierarchy: None,
is_blank: None,
layout_regions: None,
speaker_notes: None,
section_name: None,
sheet_name: None,
ocr_confidence: None,
}
}
#[test]
fn should_omit_ocr_confidence_key_when_page_was_not_ocrd() {
let page = page_content_without_ocr_confidence();
let value = serde_json::to_value(page).expect("page content must serialize");
assert!(
value
.as_object()
.expect("page content must serialize as an object")
.get("ocr_confidence")
.is_none(),
"ocr_confidence must be omitted entirely, not serialized as null"
);
}
#[test]
fn should_deserialize_legacy_page_content_missing_ocr_confidence_field() {
let legacy = json!({
"page_number": 1,
"content": "hello",
});
let page: PageContent =
serde_json::from_value(legacy).expect("page content without ocr_confidence must deserialize");
assert_eq!(page.ocr_confidence, None);
}
#[test]
fn should_round_trip_ocr_confidence_when_present() {
let mut page = page_content_without_ocr_confidence();
page.ocr_confidence = Some(PageOcrConfidence {
score: Some(0.87),
word_count: 42,
backend: "tesseract".to_string(),
});
let value = serde_json::to_value(page).expect("page content with ocr_confidence must serialize");
assert_eq!(
value["ocr_confidence"],
json!({"score": 0.87, "word_count": 42, "backend": "tesseract"})
);
}
}