use bytes::Bytes;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::collections::HashMap;
use super::djot::DjotContent;
use super::document_structure::DocumentStructure;
use super::metadata::Metadata;
use super::ocr_elements::OcrElement;
use super::page::PageContent;
use super::tables::Table;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum ExtractionMethod {
Native,
Ocr,
Mixed,
}
impl ExtractionMethod {
pub fn as_str(self) -> &'static str {
match self {
Self::Native => "native",
Self::Ocr => "ocr",
Self::Mixed => "mixed",
}
}
pub fn used_ocr(self) -> bool {
!matches!(self, Self::Native)
}
pub(crate) fn from_metadata_value(value: &str) -> Option<Self> {
match value {
"native" => Some(Self::Native),
"ocr" => Some(Self::Ocr),
"mixed" => Some(Self::Mixed),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct DocumentCounts {
pub pages: usize,
pub tables: usize,
pub images: usize,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "api", schema(no_recursion))]
pub struct ExtractedDocument {
pub content: String,
#[cfg_attr(feature = "api", schema(value_type = String))]
pub mime_type: Cow<'static, str>,
pub metadata: Metadata,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub extraction_method: Option<ExtractionMethod>,
pub tables: Vec<Table>,
#[serde(default)]
pub counts: DocumentCounts,
#[serde(skip_serializing_if = "Option::is_none")]
pub detected_languages: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub chunks: Option<Vec<Chunk>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub images: Option<Vec<ExtractedImage>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pages: Option<Vec<PageContent>>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub elements: Option<Vec<Element>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub djot_content: Option<DjotContent>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub ocr_elements: Option<Vec<OcrElement>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub document: Option<DocumentStructure>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
#[cfg(any(feature = "keywords-yake", feature = "keywords-rake"))]
pub extracted_keywords: Option<Vec<crate::keywords::Keyword>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub quality_score: Option<f64>,
#[serde(skip_serializing_if = "Vec::is_empty")]
#[serde(default)]
pub processing_warnings: Vec<ProcessingWarning>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub annotations: Option<Vec<super::annotations::PdfAnnotation>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub children: Option<Vec<ArchiveEntry>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub uris: Option<Vec<super::uri::ExtractedUri>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub revisions: Option<Vec<super::revisions::DocumentRevision>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub structured_output: Option<serde_json::Value>,
#[cfg(feature = "tree-sitter")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub code_intelligence: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub llm_usage: Option<Vec<LlmUsage>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub entities: Option<Vec<super::entity::Entity>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub summary: Option<super::summary::DocumentSummary>,
#[cfg(feature = "heuristics")]
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub extraction_confidence: Option<crate::heuristics::confidence::ExtractionConfidence>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub translation: Option<super::translation::Translation>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub page_classifications: Option<Vec<super::classification::PageClassification>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub redaction_report: Option<super::redaction::RedactionReport>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub formulas: Vec<super::formula::Formula>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub form_fields: Vec<super::form_field::PdfFormField>,
#[serde(skip)]
pub formatted_content: Option<String>,
#[serde(skip)]
#[allow(dead_code)]
#[cfg_attr(alef, alef(skip))]
pub(crate) ocr_internal_document: Option<super::internal::InternalDocument>,
#[serde(skip)]
#[allow(dead_code)]
#[cfg_attr(alef, alef(skip))]
pub(crate) internal_document: Option<super::internal::InternalDocument>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct ArchiveEntry {
pub path: String,
pub mime_type: String,
pub result: Box<ExtractedDocument>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct ProcessingWarning {
#[cfg_attr(feature = "api", schema(value_type = String))]
pub source: Cow<'static, str>,
#[cfg_attr(feature = "api", schema(value_type = String))]
pub message: Cow<'static, str>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct LlmUsage {
pub model: String,
pub source: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub input_tokens: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_tokens: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub total_tokens: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub estimated_cost: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub finish_reason: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum ChunkType {
Heading,
PartyList,
Definitions,
OperativeClause,
SignatureBlock,
Schedule,
TableLike,
Formula,
CodeBlock,
Function,
Class,
Module,
Image,
OrgChart,
Diagram,
#[default]
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct Chunk {
pub content: String,
#[serde(default)]
pub chunk_type: ChunkType,
#[serde(skip_serializing_if = "Option::is_none")]
pub embedding: Option<Vec<f32>>,
pub metadata: ChunkMetadata,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct HeadingContext {
pub headings: Vec<HeadingLevel>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct HeadingLevel {
pub level: u8,
pub text: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct ChunkMetadata {
pub byte_start: usize,
pub byte_end: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub token_count: Option<usize>,
pub chunk_index: usize,
pub total_chunks: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub first_page: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_page: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub heading_context: Option<HeadingContext>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub heading_path: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub image_indices: Vec<u32>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub node_ids: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub page_spans: Vec<PageSpan>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub classifications: Vec<super::classification::ClassificationLabel>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct PageSpan {
pub page: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub bbox: Option<BoundingBox>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum ImageKind {
Photograph,
Diagram,
Chart,
Drawing,
TextBlock,
Decoration,
Logo,
Icon,
TileFragment,
Mask,
PageRaster,
Unknown,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct ExtractedImage {
#[cfg_attr(feature = "api", schema(value_type = Vec<u8>, format = "binary"))]
pub data: Bytes,
#[cfg_attr(feature = "api", schema(value_type = String))]
pub format: Cow<'static, str>,
pub image_index: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub page_number: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub width: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub height: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub colorspace: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bits_per_component: Option<u32>,
#[serde(default)]
pub is_mask: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "api", schema(value_type = Option<ExtractedDocument>))]
pub ocr_result: Option<Box<ExtractedDocument>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub bounding_box: Option<BoundingBox>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub source_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_kind: Option<ImageKind>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind_confidence: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cluster_id: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub caption: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub qr_codes: Option<Vec<super::qr::QrCode>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data_base64: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum ResultFormat {
#[default]
Unified,
ElementBased,
}
#[cfg_attr(alef, alef(skip))]
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "api", schema(value_type = String))]
pub struct ElementId(String);
impl ElementId {
pub(crate) fn new(hex_str: impl Into<String>) -> std::result::Result<Self, String> {
let s = hex_str.into();
if s.is_empty() {
return Err("ElementId cannot be empty".to_string());
}
Ok(ElementId(s))
}
}
impl AsRef<str> for ElementId {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for ElementId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum ElementType {
Title,
NarrativeText,
Heading,
ListItem,
Table,
Image,
PageBreak,
CodeBlock,
BlockQuote,
Footer,
Header,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct BoundingBox {
pub x0: f64,
pub y0: f64,
pub x1: f64,
pub y1: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct ElementMetadata {
pub page_number: Option<u32>,
pub filename: Option<String>,
pub coordinates: Option<BoundingBox>,
pub element_index: Option<usize>,
pub additional: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct Element {
#[cfg_attr(alef, alef(skip))]
#[serde(skip)]
pub element_id: ElementId,
pub element_type: ElementType,
pub text: String,
pub metadata: ElementMetadata,
}
impl ExtractedDocument {
#[cfg_attr(alef, alef(skip))]
pub fn from_ocr(ocr: super::formats::OcrExtractionResult) -> Self {
Self {
content: ocr.content,
mime_type: Cow::Owned(ocr.mime_type),
extraction_method: Some(ExtractionMethod::Ocr),
tables: ocr.tables.into_iter().map(super::tables::Table::from_ocr).collect(),
ocr_elements: ocr.ocr_elements,
..Default::default()
}
}
}
impl super::tables::Table {
pub fn from_ocr(ocr: super::formats::OcrTable) -> Self {
Self {
cells: ocr.cells,
markdown: ocr.markdown,
page_number: ocr.page_number,
bounding_box: ocr.bounding_box.map(|b| super::extraction::BoundingBox {
x0: b.left as f64,
y0: b.top as f64,
x1: b.right as f64,
y1: b.bottom as f64,
}),
..Default::default()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chunk_metadata_omitting_heading_path_deserializes_to_empty_vec() {
let json = r#"{
"byte_start": 0,
"byte_end": 42,
"chunk_index": 0,
"total_chunks": 1
}"#;
let meta: ChunkMetadata = serde_json::from_str(json).unwrap();
assert!(
meta.heading_path.is_empty(),
"omitted heading_path must default to empty vec, got: {:?}",
meta.heading_path
);
}
#[test]
fn extraction_result_omitting_formulas_and_form_fields_defaults_to_empty() {
let json = r#"{
"content": "hello",
"mime_type": "text/plain",
"metadata": {},
"tables": []
}"#;
let result: ExtractedDocument = serde_json::from_str(json).unwrap();
assert!(result.formulas.is_empty(), "omitted formulas must default to empty vec");
assert!(
result.form_fields.is_empty(),
"omitted form_fields must default to empty vec"
);
}
#[test]
fn extraction_result_omitting_counts_defaults_to_zero() {
let json = r#"{
"content": "hello",
"mime_type": "text/plain",
"metadata": {},
"tables": []
}"#;
let result: ExtractedDocument = serde_json::from_str(json).unwrap();
assert_eq!(
result.counts,
DocumentCounts::default(),
"omitted counts must default to all-zero DocumentCounts"
);
}
#[test]
fn document_counts_round_trip() {
let counts = DocumentCounts {
pages: 7,
tables: 3,
images: 2,
};
let json = serde_json::to_string(&counts).unwrap();
let back: DocumentCounts = serde_json::from_str(&json).unwrap();
assert_eq!(counts, back);
}
fn empty_chunk_metadata() -> ChunkMetadata {
ChunkMetadata {
byte_start: 0,
byte_end: 10,
token_count: None,
chunk_index: 0,
total_chunks: 1,
first_page: None,
last_page: None,
heading_context: None,
heading_path: Vec::new(),
image_indices: Vec::new(),
node_ids: Vec::new(),
page_spans: Vec::new(),
classifications: Vec::new(),
}
}
#[test]
fn chunk_metadata_node_ids_omitted_when_empty() {
let meta = empty_chunk_metadata();
let json = serde_json::to_value(&meta).expect("serialize");
assert!(
json.get("node_ids").is_none(),
"empty node_ids must be omitted from the wire, got: {json:?}"
);
}
#[test]
fn chunk_metadata_node_ids_present_when_set() {
let mut meta = empty_chunk_metadata();
meta.node_ids = vec![
crate::types::document_structure::NodeId::generate("paragraph", "a", Some(1), 0).to_string(),
crate::types::document_structure::NodeId::generate("paragraph", "b", Some(1), 1).to_string(),
];
let json = serde_json::to_value(&meta).expect("serialize");
let ids = json
.get("node_ids")
.expect("node_ids present")
.as_array()
.expect("array");
assert_eq!(ids.len(), 2);
assert!(ids[0].is_string(), "node ids must serialize as bare strings");
let back: ChunkMetadata = serde_json::from_value(json).expect("deserialize");
assert_eq!(back.node_ids, meta.node_ids);
}
#[test]
fn chunk_metadata_omitting_node_ids_deserializes_to_empty_vec() {
let json = r#"{
"byte_start": 0,
"byte_end": 42,
"chunk_index": 0,
"total_chunks": 1
}"#;
let meta: ChunkMetadata = serde_json::from_str(json).unwrap();
assert!(meta.node_ids.is_empty(), "omitted node_ids must default to empty vec");
}
#[test]
fn chunk_metadata_page_spans_omitted_when_empty() {
let meta = empty_chunk_metadata();
let json = serde_json::to_value(&meta).expect("serialize");
assert!(
json.get("page_spans").is_none(),
"empty page_spans must be omitted from the wire, got: {json:?}"
);
}
#[test]
fn chunk_metadata_page_spans_present_when_set() {
let mut meta = empty_chunk_metadata();
meta.page_spans = vec![
PageSpan {
page: 1,
bbox: Some(BoundingBox {
x0: 0.0,
y0: 0.0,
x1: 100.0,
y1: 200.0,
}),
},
PageSpan { page: 2, bbox: None },
];
let json = serde_json::to_value(&meta).expect("serialize");
let spans = json
.get("page_spans")
.expect("page_spans present")
.as_array()
.expect("array");
assert_eq!(spans.len(), 2);
assert!(spans[0].get("bbox").is_some());
assert!(spans[1].get("bbox").is_none(), "None bbox must be omitted per-span");
let back: ChunkMetadata = serde_json::from_value(json).expect("deserialize");
assert_eq!(back.page_spans, meta.page_spans);
}
#[test]
fn chunk_metadata_omitting_page_spans_deserializes_to_empty_vec() {
let json = r#"{
"byte_start": 0,
"byte_end": 42,
"chunk_index": 0,
"total_chunks": 1
}"#;
let meta: ChunkMetadata = serde_json::from_str(json).unwrap();
assert!(
meta.page_spans.is_empty(),
"omitted page_spans must default to empty vec"
);
}
#[test]
fn extraction_result_formula_round_trip() {
use super::super::formula::Formula;
let formula = Formula {
latex: r"E = mc^2".to_string(),
bbox: BoundingBox {
x0: 10.0,
y0: 20.0,
x1: 100.0,
y1: 50.0,
},
page: 1,
};
let result = ExtractedDocument {
content: "Physics document".to_string(),
mime_type: std::borrow::Cow::Borrowed("application/pdf"),
formulas: vec![formula],
..Default::default()
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("formulas"), "non-empty formulas must be serialized");
let deserialized: ExtractedDocument = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.formulas.len(), 1);
assert_eq!(deserialized.formulas[0].latex, r"E = mc^2");
assert_eq!(deserialized.formulas[0].page, 1);
assert_eq!(deserialized.formulas[0].bbox.x0, 10.0);
}
#[test]
fn extraction_result_pdf_form_field_round_trip() {
use super::super::form_field::{FormFieldType, PdfFormField};
let field = PdfFormField {
name: "FirstName".to_string(),
full_name: "PersonalInfo.FirstName".to_string(),
field_type: FormFieldType::Text,
value: Some("Alice".to_string()),
default_value: None,
flags: 0,
page: Some(1),
bbox: Some(BoundingBox {
x0: 72.0,
y0: 300.0,
x1: 300.0,
y1: 320.0,
}),
max_length: Some(50),
tooltip: Some("Enter your first name".to_string()),
};
let result = ExtractedDocument {
content: "Form document".to_string(),
mime_type: std::borrow::Cow::Borrowed("application/pdf"),
form_fields: vec![field],
..Default::default()
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("form_fields"), "non-empty form_fields must be serialized");
let deserialized: ExtractedDocument = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.form_fields.len(), 1);
assert_eq!(deserialized.form_fields[0].name, "FirstName");
assert_eq!(deserialized.form_fields[0].full_name, "PersonalInfo.FirstName");
assert_eq!(deserialized.form_fields[0].field_type, FormFieldType::Text);
assert_eq!(deserialized.form_fields[0].value.as_deref(), Some("Alice"));
assert_eq!(deserialized.form_fields[0].max_length, Some(50));
let bbox = deserialized.form_fields[0].bbox.unwrap();
assert_eq!(bbox.x0, 72.0);
assert_eq!(bbox.y1, 320.0);
}
}