use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Rect {
pub x0: f32,
pub y0: f32,
pub x1: f32,
pub y1: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BlockKind {
Heading,
#[default]
Paragraph,
List,
Code,
Table,
Image,
Quote,
Chart,
Note,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct TableData {
pub rows: Vec<Vec<String>>,
#[serde(default, skip_serializing_if = "is_zero")]
pub header_rows: usize,
}
fn is_zero(n: &usize) -> bool {
*n == 0
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum MetaValue {
Bool(bool),
Str(String),
List(Vec<String>),
}
impl From<bool> for MetaValue {
fn from(v: bool) -> Self {
MetaValue::Bool(v)
}
}
impl From<String> for MetaValue {
fn from(v: String) -> Self {
MetaValue::Str(v)
}
}
impl From<&str> for MetaValue {
fn from(v: &str) -> Self {
MetaValue::Str(v.to_string())
}
}
impl From<Vec<String>> for MetaValue {
fn from(v: Vec<String>) -> Self {
MetaValue::List(v)
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Block {
#[serde(rename = "kind")]
pub kind: BlockKind,
#[serde(default)]
pub content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bbox: Option<Rect>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub level: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ordered: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lang: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub table: Option<TableData>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub meta: BTreeMap<String, MetaValue>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Image {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub data: Vec<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bbox: Option<Rect>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dedup_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ocr_text: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Page {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default)]
pub blocks: Vec<Block>,
#[serde(default)]
pub text: String,
#[serde(default)]
pub images: Vec<Image>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub width: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub height: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rotation: Option<i32>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct DocMeta {
pub file_type: String,
pub page_count: usize,
pub text_length: usize,
pub image_count: usize,
pub image_text_length: usize,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub extra: std::collections::BTreeMap<String, String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Document {
pub meta: DocMeta,
#[serde(default)]
pub pages: Vec<Page>,
}
impl Document {
pub fn finalize(mut self) -> Self {
self.meta.page_count = self.pages.len();
self.meta.text_length = self.pages.iter().map(|p| p.text.chars().count()).sum();
self.meta.image_count = self.pages.iter().map(|p| p.images.len()).sum();
self.meta.image_text_length = self
.pages
.iter()
.flat_map(|p| &p.images)
.map(|im| {
im.ocr_text
.as_deref()
.map(|s| s.chars().count())
.unwrap_or(0)
})
.sum();
self
}
}
pub fn blocks_to_text(blocks: &[Block], sep: &str) -> String {
let parts: Vec<String> = blocks
.iter()
.filter_map(|b| match b.kind {
BlockKind::Table => b.table.as_ref().map(|t| {
t.rows
.iter()
.map(|r| r.join(","))
.collect::<Vec<_>>()
.join("\n")
}),
_ if !b.content.is_empty() => Some(b.content.clone()),
_ => None,
})
.collect();
parts.join(sep)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn document_finalize_counts() {
let doc = Document {
pages: vec![
Page {
text: "你好".into(),
images: vec![Image {
ocr_text: Some("x".into()),
..Default::default()
}],
..Default::default()
},
Page {
text: "world".into(),
..Default::default()
},
],
..Default::default()
}
.finalize();
assert_eq!(doc.meta.page_count, 2);
assert_eq!(doc.meta.text_length, 7); assert_eq!(doc.meta.image_count, 1);
assert_eq!(doc.meta.image_text_length, 1);
}
#[test]
fn blocks_to_text_expands_tables() {
let b = Block {
kind: BlockKind::Table,
table: Some(TableData {
rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
..Default::default()
}),
..Default::default()
};
let txt = blocks_to_text(&[b], "\n\n");
assert_eq!(txt, "a,b\n1,2");
}
#[test]
fn block_meta_serializes_like_python() {
let mut meta = BTreeMap::new();
meta.insert("merges".to_string(), vec!["A1:B1".to_string()].into());
meta.insert("truncated".to_string(), false.into());
meta.insert("region".to_string(), "notes".into());
let block = Block {
kind: BlockKind::Table,
meta,
..Default::default()
};
let json = serde_json::to_string(&block).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["meta"]["merges"], serde_json::json!(["A1:B1"]));
assert_eq!(v["meta"]["truncated"], serde_json::json!(false));
assert_eq!(v["meta"]["region"], serde_json::json!("notes"));
let back: Block = serde_json::from_str(&json).unwrap();
assert_eq!(back, block);
}
#[test]
fn empty_meta_is_omitted() {
let json = serde_json::to_string(&Block::default()).unwrap();
assert!(!json.contains("meta"), "{json}");
}
#[test]
fn block_kind_wire_values_match_python() {
let cases = [
(BlockKind::Heading, "heading"),
(BlockKind::Paragraph, "paragraph"),
(BlockKind::List, "list"),
(BlockKind::Code, "code"),
(BlockKind::Table, "table"),
(BlockKind::Image, "image"),
(BlockKind::Quote, "quote"),
(BlockKind::Chart, "chart"),
(BlockKind::Note, "note"),
];
for (kind, wire) in cases {
let json = serde_json::to_string(&kind).unwrap();
assert_eq!(json, format!("\"{wire}\""), "{kind:?}");
assert_eq!(serde_json::from_str::<BlockKind>(&json).unwrap(), kind);
}
}
#[test]
fn roundtrip_json() {
let doc = Document {
pages: vec![Page {
text: "t".into(),
blocks: vec![Block {
kind: BlockKind::Paragraph,
content: "hi".into(),
..Default::default()
}],
..Default::default()
}],
..Default::default()
};
let s = serde_json::to_string(&doc).unwrap();
let back: Document = serde_json::from_str(&s).unwrap();
assert_eq!(back, doc);
}
}