use std::borrow::Cow;
use ahash::AHashMap;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::collections::{BTreeMap, HashMap};
#[cfg(feature = "pdf")]
pub use crate::pdf::metadata::PdfMetadata;
use super::formats::ImagePreprocessingMetadata;
use super::page::PageStructure;
mod additional_serde {
use super::*;
pub(crate) fn serialize<S>(
map: &AHashMap<Cow<'static, str>, serde_json::Value>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let converted: HashMap<String, serde_json::Value> =
map.iter().map(|(k, v)| (k.to_string(), v.clone())).collect();
converted.serialize(serializer)
}
pub(crate) fn deserialize<'de, D>(
deserializer: D,
) -> Result<AHashMap<Cow<'static, str>, serde_json::Value>, D::Error>
where
D: Deserializer<'de>,
{
let map = HashMap::<String, serde_json::Value>::deserialize(deserializer)?;
let result = map.into_iter().map(|(k, v)| (Cow::Owned(k), v)).collect();
Ok(result)
}
pub(crate) fn is_empty(map: &AHashMap<Cow<'static, str>, serde_json::Value>) -> bool {
map.is_empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "format_type", rename_all = "snake_case")]
pub enum FormatMetadata {
#[cfg(feature = "pdf")]
Pdf(PdfMetadata),
#[cfg(feature = "office")]
Docx(Box<DocxMetadata>),
Excel(ExcelMetadata),
Email(EmailMetadata),
Pptx(PptxMetadata),
Archive(ArchiveMetadata),
Image(ImageMetadata),
Xml(XmlMetadata),
Text(TextMetadata),
Html(Box<HtmlMetadata>),
Ocr(OcrMetadata),
Csv(CsvMetadata),
#[cfg(feature = "office")]
Bibtex(BibtexMetadata),
#[cfg(feature = "office")]
Citation(CitationMetadata),
#[cfg(feature = "office")]
FictionBook(FictionBookMetadata),
#[cfg(feature = "office")]
Dbf(DbfMetadata),
#[cfg(feature = "xml")]
Jats(JatsMetadata),
#[cfg(feature = "office")]
Epub(EpubMetadata),
Pst(PstMetadata),
#[cfg(feature = "transcription-types")]
Audio(AudioMetadata),
#[cfg(feature = "tree-sitter")]
Code(CodeMetadata),
}
#[cfg(feature = "tree-sitter")]
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct CodeMetadata {
pub chunks: Vec<CodeChunkInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data: Option<CodeDataNode>,
}
#[cfg(feature = "tree-sitter")]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct CodeChunkInfo {
pub text: String,
pub context_path: Vec<String>,
pub node_types: Vec<String>,
pub byte_start: usize,
pub byte_end: usize,
}
#[cfg(feature = "tree-sitter")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub enum CodeDataNodeKind {
#[default]
KeyValue,
Element,
Sequence,
}
#[cfg(feature = "tree-sitter")]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct CodeDataAttribute {
pub name: String,
pub value: String,
pub byte_start: usize,
pub byte_end: usize,
}
#[cfg(feature = "tree-sitter")]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "api", schema(no_recursion))]
pub struct CodeDataNode {
pub kind: CodeDataNodeKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attributes: Vec<CodeDataAttribute>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub children: Vec<CodeDataNode>,
pub byte_start: usize,
pub byte_end: usize,
}
impl Default for FormatMetadata {
fn default() -> Self {
Self::Text(TextMetadata {
line_count: 0,
word_count: 0,
character_count: 0,
headers: None,
links: None,
code_blocks: None,
})
}
}
impl FormatMetadata {
pub fn excel(&self) -> Option<&ExcelMetadata> {
if let Self::Excel(e) = self { Some(e) } else { None }
}
pub fn html(&self) -> Option<&HtmlMetadata> {
if let Self::Html(h) = self { Some(h) } else { None }
}
}
impl std::fmt::Display for FormatMetadata {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
#[cfg(feature = "pdf")]
Self::Pdf(_) => f.write_str("pdf"),
#[cfg(feature = "office")]
Self::Docx(_) => f.write_str("docx"),
Self::Excel(_) => f.write_str("excel"),
Self::Email(_) => f.write_str("email"),
Self::Pptx(_) => f.write_str("pptx"),
Self::Archive(_) => f.write_str("archive"),
Self::Image(image) => f.write_str(&image.format.to_uppercase()),
Self::Xml(_) => f.write_str("xml"),
Self::Text(_) => f.write_str("text"),
Self::Html(_) => f.write_str("html"),
Self::Ocr(_) => f.write_str("ocr"),
Self::Csv(_) => f.write_str("csv"),
#[cfg(feature = "office")]
Self::Bibtex(_) => f.write_str("bibtex"),
#[cfg(feature = "office")]
Self::Citation(_) => f.write_str("citation"),
#[cfg(feature = "office")]
Self::FictionBook(_) => f.write_str("fictionbook"),
#[cfg(feature = "office")]
Self::Dbf(_) => f.write_str("dbf"),
#[cfg(feature = "xml")]
Self::Jats(_) => f.write_str("jats"),
#[cfg(feature = "office")]
Self::Epub(_) => f.write_str("epub"),
Self::Pst(_) => f.write_str("pst"),
#[cfg(feature = "transcription-types")]
Self::Audio(_) => f.write_str("audio"),
#[cfg(feature = "tree-sitter")]
Self::Code(_) => f.write_str("code"),
}
}
}
#[cfg(feature = "api")]
impl utoipa::PartialSchema for FormatMetadata {
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
use utoipa::openapi::Ref;
use utoipa::openapi::schema::{Discriminator, OneOfBuilder};
let builder = OneOfBuilder::new()
.description(Some(
"Format-specific metadata (discriminated union). \
Only one format type can exist per extraction result.",
))
.discriminator(Some(Discriminator::with_mapping(
"format_type",
[
#[cfg(feature = "pdf")]
("pdf", "#/components/schemas/PdfMetadata"),
#[cfg(feature = "office")]
("docx", "#/components/schemas/DocxMetadata"),
("excel", "#/components/schemas/ExcelMetadata"),
("email", "#/components/schemas/EmailMetadata"),
("pptx", "#/components/schemas/PptxMetadata"),
("archive", "#/components/schemas/ArchiveMetadata"),
("image", "#/components/schemas/ImageMetadata"),
("xml", "#/components/schemas/XmlMetadata"),
("text", "#/components/schemas/TextMetadata"),
("html", "#/components/schemas/HtmlMetadata"),
("ocr", "#/components/schemas/OcrMetadata"),
("csv", "#/components/schemas/CsvMetadata"),
#[cfg(feature = "office")]
("bibtex", "#/components/schemas/BibtexMetadata"),
#[cfg(feature = "office")]
("citation", "#/components/schemas/CitationMetadata"),
#[cfg(feature = "office")]
("fiction_book", "#/components/schemas/FictionBookMetadata"),
#[cfg(feature = "office")]
("dbf", "#/components/schemas/DbfMetadata"),
#[cfg(feature = "xml")]
("jats", "#/components/schemas/JatsMetadata"),
#[cfg(feature = "office")]
("epub", "#/components/schemas/EpubMetadata"),
("pst", "#/components/schemas/PstMetadata"),
#[cfg(feature = "transcription-types")]
("audio", "#/components/schemas/AudioMetadata"),
#[cfg(feature = "tree-sitter")]
("code", "#/components/schemas/CodeMetadata"),
],
)));
let builder = {
#[cfg(feature = "pdf")]
let builder = builder.item(Ref::from_schema_name("PdfMetadata"));
#[cfg(not(feature = "pdf"))]
let builder = builder;
#[cfg(feature = "office")]
let builder = builder.item(Ref::from_schema_name("DocxMetadata"));
#[cfg(not(feature = "office"))]
let builder = builder;
let builder = builder
.item(Ref::from_schema_name("ExcelMetadata"))
.item(Ref::from_schema_name("EmailMetadata"))
.item(Ref::from_schema_name("PptxMetadata"))
.item(Ref::from_schema_name("ArchiveMetadata"))
.item(Ref::from_schema_name("ImageMetadata"))
.item(Ref::from_schema_name("XmlMetadata"))
.item(Ref::from_schema_name("TextMetadata"))
.item(Ref::from_schema_name("HtmlMetadata"))
.item(Ref::from_schema_name("OcrMetadata"))
.item(Ref::from_schema_name("CsvMetadata"));
#[cfg(feature = "office")]
let builder = builder
.item(Ref::from_schema_name("BibtexMetadata"))
.item(Ref::from_schema_name("CitationMetadata"))
.item(Ref::from_schema_name("FictionBookMetadata"))
.item(Ref::from_schema_name("DbfMetadata"))
.item(Ref::from_schema_name("EpubMetadata"));
#[cfg(not(feature = "office"))]
let builder = builder;
#[cfg(feature = "xml")]
let builder = builder.item(Ref::from_schema_name("JatsMetadata"));
#[cfg(not(feature = "xml"))]
let builder = builder;
let builder = builder.item(Ref::from_schema_name("PstMetadata"));
#[cfg(feature = "transcription-types")]
let builder = builder.item(Ref::from_schema_name("AudioMetadata"));
#[cfg(not(feature = "transcription-types"))]
let builder = builder;
#[cfg(feature = "tree-sitter")]
let builder = builder.item(Ref::from_schema_name("CodeMetadata"));
#[cfg(not(feature = "tree-sitter"))]
let builder = builder;
builder
};
builder.into()
}
}
#[cfg(feature = "api")]
impl utoipa::ToSchema for FormatMetadata {
fn schemas(schemas: &mut Vec<(String, utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>)>) {
use utoipa::{PartialSchema, ToSchema};
macro_rules! push_schema {
($t:ty) => {
schemas.push((<$t as ToSchema>::name().into(), <$t as PartialSchema>::schema()));
<$t as ToSchema>::schemas(schemas);
};
}
#[cfg(feature = "pdf")]
push_schema!(PdfMetadata);
#[cfg(feature = "office")]
{
push_schema!(DocxMetadata);
push_schema!(BibtexMetadata);
push_schema!(CitationMetadata);
push_schema!(FictionBookMetadata);
push_schema!(DbfMetadata);
push_schema!(EpubMetadata);
}
#[cfg(feature = "xml")]
push_schema!(JatsMetadata);
push_schema!(ExcelMetadata);
push_schema!(EmailMetadata);
push_schema!(PptxMetadata);
push_schema!(ArchiveMetadata);
push_schema!(ImageMetadata);
push_schema!(XmlMetadata);
push_schema!(TextMetadata);
push_schema!(HtmlMetadata);
push_schema!(OcrMetadata);
push_schema!(CsvMetadata);
push_schema!(PstMetadata);
#[cfg(feature = "transcription-types")]
push_schema!(AudioMetadata);
#[cfg(feature = "tree-sitter")]
push_schema!(CodeMetadata);
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct Metadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subject: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub authors: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub keywords: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub modified_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_by: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub modified_by: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pages: Option<PageStructure>,
#[serde(skip_serializing_if = "Option::is_none")]
pub format: Option<FormatMetadata>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_preprocessing: Option<ImagePreprocessingMetadata>,
#[serde(skip_serializing_if = "Option::is_none")]
pub json_schema: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<ErrorMetadata>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extraction_duration_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub document_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub abstract_text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_format: Option<String>,
#[serde(default)]
pub ocr_used: bool,
#[serde(
skip_serializing_if = "additional_serde::is_empty",
serialize_with = "additional_serde::serialize",
deserialize_with = "additional_serde::deserialize",
default
)]
#[cfg_attr(feature = "api", schema(value_type = HashMap<String, serde_json::Value>))]
pub additional: AHashMap<Cow<'static, str>, serde_json::Value>,
}
impl Metadata {
pub fn is_empty(&self) -> bool {
self.title.is_none()
&& self.subject.is_none()
&& self.authors.is_none()
&& self.keywords.is_none()
&& self.language.is_none()
&& self.created_at.is_none()
&& self.modified_at.is_none()
&& self.created_by.is_none()
&& self.modified_by.is_none()
&& self.pages.is_none()
&& self.format.is_none()
&& self.image_preprocessing.is_none()
&& self.json_schema.is_none()
&& self.error.is_none()
&& self.extraction_duration_ms.is_none()
&& self.category.is_none()
&& self.tags.is_none()
&& self.document_version.is_none()
&& self.abstract_text.is_none()
&& self.output_format.is_none()
&& !self.ocr_used
&& self.additional.is_empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct ExcelMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub sheet_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sheet_names: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct EmailMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub from_email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub from_name: Option<String>,
pub to_emails: Vec<String>,
pub cc_emails: Vec<String>,
pub bcc_emails: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message_id: Option<String>,
pub attachments: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct ArchiveMetadata {
#[cfg_attr(feature = "api", schema(value_type = String))]
pub format: Cow<'static, str>,
pub file_count: u32,
pub file_list: Vec<String>,
pub total_size: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub compressed_size: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct ImageMetadata {
pub width: u32,
pub height: u32,
pub format: String,
pub exif: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct XmlMetadata {
pub element_count: u32,
pub unique_elements: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct MarkdownLink {
pub text: String,
pub url: String,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum MarkdownLinkWire {
Positional((String, String)),
Named { text: String, url: String },
}
impl<'de> Deserialize<'de> for MarkdownLink {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
Ok(match MarkdownLinkWire::deserialize(deserializer)? {
MarkdownLinkWire::Positional(link) => link.into(),
MarkdownLinkWire::Named { text, url } => Self { text, url },
})
}
}
impl From<(String, String)> for MarkdownLink {
fn from((text, url): (String, String)) -> Self {
Self { text, url }
}
}
impl From<MarkdownLink> for (String, String) {
fn from(link: MarkdownLink) -> Self {
(link.text, link.url)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct MarkdownCodeBlock {
pub language: String,
pub code: String,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum MarkdownCodeBlockWire {
Positional((String, String)),
Named { language: String, code: String },
}
impl<'de> Deserialize<'de> for MarkdownCodeBlock {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
Ok(match MarkdownCodeBlockWire::deserialize(deserializer)? {
MarkdownCodeBlockWire::Positional(block) => block.into(),
MarkdownCodeBlockWire::Named { language, code } => Self { language, code },
})
}
}
impl From<(String, String)> for MarkdownCodeBlock {
fn from((language, code): (String, String)) -> Self {
Self { language, code }
}
}
impl From<MarkdownCodeBlock> for (String, String) {
fn from(block: MarkdownCodeBlock) -> Self {
(block.language, block.code)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct KeyValueAttribute {
pub key: String,
pub value: String,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum KeyValueAttributeWire {
Positional((String, String)),
Named { key: String, value: String },
}
impl<'de> Deserialize<'de> for KeyValueAttribute {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
Ok(match KeyValueAttributeWire::deserialize(deserializer)? {
KeyValueAttributeWire::Positional(attribute) => attribute.into(),
KeyValueAttributeWire::Named { key, value } => Self { key, value },
})
}
}
impl From<(String, String)> for KeyValueAttribute {
fn from((key, value): (String, String)) -> Self {
Self { key, value }
}
}
impl From<KeyValueAttribute> for (String, String) {
fn from(attribute: KeyValueAttribute) -> Self {
(attribute.key, attribute.value)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
pub struct ImageDimensions {
pub width: u32,
pub height: u32,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum ImageDimensionsWire {
Positional((u32, u32)),
Named { width: u32, height: u32 },
}
impl<'de> Deserialize<'de> for ImageDimensions {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
Ok(match ImageDimensionsWire::deserialize(deserializer)? {
ImageDimensionsWire::Positional(dimensions) => dimensions.into(),
ImageDimensionsWire::Named { width, height } => Self { width, height },
})
}
}
#[cfg(feature = "api")]
fn named_pair_schema(
first_field: &str,
second_field: &str,
item_type: utoipa::openapi::schema::Type,
) -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
use utoipa::openapi::schema::{Object, ObjectBuilder, Type};
ObjectBuilder::new()
.schema_type(Type::Object)
.property(first_field, Object::with_type(item_type.clone()))
.required(first_field)
.property(second_field, Object::with_type(item_type))
.required(second_field)
.into()
}
#[cfg(feature = "api")]
impl utoipa::PartialSchema for MarkdownLink {
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
named_pair_schema("text", "url", utoipa::openapi::schema::Type::String)
}
}
#[cfg(feature = "api")]
impl utoipa::ToSchema for MarkdownLink {}
#[cfg(feature = "api")]
impl utoipa::PartialSchema for MarkdownCodeBlock {
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
named_pair_schema("language", "code", utoipa::openapi::schema::Type::String)
}
}
#[cfg(feature = "api")]
impl utoipa::ToSchema for MarkdownCodeBlock {}
#[cfg(feature = "api")]
impl utoipa::PartialSchema for KeyValueAttribute {
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
named_pair_schema("key", "value", utoipa::openapi::schema::Type::String)
}
}
#[cfg(feature = "api")]
impl utoipa::ToSchema for KeyValueAttribute {}
#[cfg(feature = "api")]
impl utoipa::PartialSchema for ImageDimensions {
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
named_pair_schema("width", "height", utoipa::openapi::schema::Type::Integer)
}
}
#[cfg(feature = "api")]
impl utoipa::ToSchema for ImageDimensions {}
impl From<(u32, u32)> for ImageDimensions {
fn from((width, height): (u32, u32)) -> Self {
Self { width, height }
}
}
impl From<ImageDimensions> for (u32, u32) {
fn from(dimensions: ImageDimensions) -> Self {
(dimensions.width, dimensions.height)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct TextMetadata {
pub line_count: u32,
pub word_count: u32,
pub character_count: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub headers: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub links: Option<Vec<MarkdownLink>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub code_blocks: Option<Vec<MarkdownCodeBlock>>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
#[serde(rename_all = "lowercase")]
pub enum TextDirection {
#[serde(rename = "ltr")]
LeftToRight,
#[serde(rename = "rtl")]
RightToLeft,
#[serde(rename = "auto")]
Auto,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct HeaderMetadata {
pub level: u8,
pub text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub depth: u32,
pub html_offset: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct LinkMetadata {
pub href: String,
pub text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
pub link_type: LinkType,
pub rel: Vec<String>,
pub attributes: Vec<KeyValueAttribute>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
#[serde(rename_all = "lowercase")]
pub enum LinkType {
Anchor,
Internal,
External,
Email,
Phone,
Other,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct ImageMetadataType {
pub src: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub alt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
pub dimensions: Option<ImageDimensions>,
pub image_type: ImageType,
pub attributes: Vec<KeyValueAttribute>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
#[serde(rename_all = "lowercase")]
pub enum ImageType {
#[serde(rename = "data-uri")]
DataUri,
#[serde(rename = "inline-svg")]
InlineSvg,
External,
Relative,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct StructuredData {
pub data_type: StructuredDataType,
pub raw_json: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub schema_type: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
#[serde(rename_all = "lowercase")]
pub enum StructuredDataType {
#[serde(rename = "json-ld")]
JsonLd,
Microdata,
#[serde(rename = "rdfa")]
RDFa,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct HtmlMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub keywords: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub canonical_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub base_href: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text_direction: Option<TextDirection>,
#[serde(default)]
pub open_graph: BTreeMap<String, String>,
#[serde(default)]
pub twitter_card: BTreeMap<String, String>,
#[serde(default)]
pub meta_tags: BTreeMap<String, String>,
#[serde(default)]
pub headers: Vec<HeaderMetadata>,
#[serde(default)]
pub links: Vec<LinkMetadata>,
#[serde(default)]
pub images: Vec<ImageMetadataType>,
#[serde(default)]
pub structured_data: Vec<StructuredData>,
}
impl HtmlMetadata {
#[cfg(feature = "html")]
pub(crate) fn is_empty(&self) -> bool {
self.title.is_none()
&& self.description.is_none()
&& self.keywords.is_empty()
&& self.author.is_none()
&& self.canonical_url.is_none()
&& self.base_href.is_none()
&& self.language.is_none()
&& self.text_direction.is_none()
&& self.open_graph.is_empty()
&& self.twitter_card.is_empty()
&& self.meta_tags.is_empty()
&& self.headers.is_empty()
&& self.links.is_empty()
&& self.images.is_empty()
&& self.structured_data.is_empty()
}
}
#[cfg(feature = "html")]
impl From<html_to_markdown_rs::HtmlMetadata> for HtmlMetadata {
fn from(metadata: html_to_markdown_rs::HtmlMetadata) -> Self {
let text_dir = metadata.document.text_direction.map(|td| match td {
html_to_markdown_rs::TextDirection::LeftToRight => TextDirection::LeftToRight,
html_to_markdown_rs::TextDirection::RightToLeft => TextDirection::RightToLeft,
html_to_markdown_rs::TextDirection::Auto => TextDirection::Auto,
});
HtmlMetadata {
title: metadata.document.title,
description: metadata.document.description,
keywords: metadata.document.keywords,
author: metadata.document.author,
canonical_url: metadata.document.canonical_url,
base_href: metadata.document.base_href,
language: metadata.document.language,
text_direction: text_dir,
open_graph: metadata.document.open_graph,
twitter_card: metadata.document.twitter_card,
meta_tags: metadata.document.meta_tags,
headers: metadata
.headers
.into_iter()
.map(|h| HeaderMetadata {
level: h.level,
text: h.text,
id: h.id,
depth: h.depth as u32,
html_offset: h.html_offset as u32,
})
.collect(),
links: metadata
.links
.into_iter()
.map(|l| LinkMetadata {
href: l.href,
text: l.text,
title: l.title,
link_type: match l.link_type {
html_to_markdown_rs::LinkType::Anchor => LinkType::Anchor,
html_to_markdown_rs::LinkType::Internal => LinkType::Internal,
html_to_markdown_rs::LinkType::External => LinkType::External,
html_to_markdown_rs::LinkType::Email => LinkType::Email,
html_to_markdown_rs::LinkType::Phone => LinkType::Phone,
html_to_markdown_rs::LinkType::Other => LinkType::Other,
},
rel: l.rel,
attributes: l.attributes.into_iter().map(Into::into).collect(),
})
.collect(),
images: metadata
.images
.into_iter()
.map(|img| ImageMetadataType {
src: img.src,
alt: img.alt,
title: img.title,
dimensions: img.dimensions.map(|d| ImageDimensions {
width: d.width,
height: d.height,
}),
image_type: match img.image_type {
html_to_markdown_rs::ImageType::DataUri => ImageType::DataUri,
html_to_markdown_rs::ImageType::InlineSvg => ImageType::InlineSvg,
html_to_markdown_rs::ImageType::External => ImageType::External,
html_to_markdown_rs::ImageType::Relative => ImageType::Relative,
},
attributes: img.attributes.into_iter().map(Into::into).collect(),
})
.collect(),
structured_data: metadata
.structured_data
.into_iter()
.map(|sd| StructuredData {
data_type: match sd.data_type {
html_to_markdown_rs::StructuredDataType::JsonLd => StructuredDataType::JsonLd,
html_to_markdown_rs::StructuredDataType::Microdata => StructuredDataType::Microdata,
html_to_markdown_rs::StructuredDataType::RDFa => StructuredDataType::RDFa,
},
raw_json: sd.raw_json,
schema_type: sd.schema_type,
})
.collect(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct OcrMetadata {
pub language: String,
pub psm: i32,
pub output_format: String,
pub table_count: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub table_rows: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub table_cols: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct ErrorMetadata {
pub error_type: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct PptxMetadata {
pub slide_count: u32,
pub slide_names: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub table_count: Option<u32>,
}
#[cfg(feature = "office")]
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct DocxMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "api", schema(value_type = Option<Object>))]
pub core_properties: Option<crate::extraction::office_metadata::CoreProperties>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "api", schema(value_type = Option<Object>))]
pub app_properties: Option<crate::extraction::office_metadata::DocxAppProperties>,
#[serde(skip_serializing_if = "Option::is_none")]
pub custom_properties: Option<HashMap<String, serde_json::Value>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct CsvMetadata {
pub row_count: u32,
pub column_count: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub delimiter: Option<String>,
pub has_header: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub column_types: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct BibtexMetadata {
pub entry_count: usize,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub citation_keys: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub authors: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub year_range: Option<YearRange>,
#[serde(skip_serializing_if = "Option::is_none")]
pub entry_types: Option<BTreeMap<String, usize>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct CitationMetadata {
pub citation_count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub format: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub authors: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub year_range: Option<YearRange>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub dois: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub keywords: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct YearRange {
#[serde(skip_serializing_if = "Option::is_none")]
pub min: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max: Option<u32>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub years: Vec<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct FictionBookMetadata {
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub genres: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub sequences: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub annotation: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct DbfMetadata {
pub record_count: usize,
pub field_count: usize,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub fields: Vec<DbfFieldInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct DbfFieldInfo {
pub name: String,
pub field_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct JatsMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub copyright: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
#[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
pub history_dates: BTreeMap<String, String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub contributor_roles: Vec<ContributorRole>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct ContributorRole {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct EpubMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub coverage: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dc_format: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub relation: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dc_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cover_image: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct PstMetadata {
pub message_count: usize,
}
#[cfg(feature = "transcription-types")]
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct AudioMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub codec: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub container: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sample_rate_hz: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub channels: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bitrate: Option<u32>,
}
#[cfg(all(test, feature = "tree-sitter"))]
mod code_metadata_serde_tests {
use super::{CodeChunkInfo, CodeMetadata, FormatMetadata};
#[test]
fn code_variant_round_trips_through_json() {
let value = FormatMetadata::Code(CodeMetadata {
chunks: vec![CodeChunkInfo {
text: "fn main() {}".to_string(),
context_path: vec!["main".to_string()],
node_types: vec!["function_item".to_string()],
byte_start: 0,
byte_end: 12,
}],
data: None,
});
let json = serde_json::to_string(&value).expect("Code metadata must serialize");
assert!(
json.contains("\"format_type\":\"code\""),
"internal tag present: {json}"
);
assert!(json.contains("\"chunks\""), "chunks field present: {json}");
let back: FormatMetadata = serde_json::from_str(&json).expect("Code metadata must deserialize");
let FormatMetadata::Code(CodeMetadata { chunks, .. }) = back else {
panic!("expected Code variant after round-trip");
};
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0].node_types, vec!["function_item".to_string()]);
}
}
#[cfg(test)]
mod binding_value_serde_tests {
use super::{ImageDimensions, KeyValueAttribute, MarkdownCodeBlock, MarkdownLink, TextMetadata};
use serde_json::json;
#[cfg(feature = "api")]
fn assert_named_object_schema<T: utoipa::PartialSchema>(fields: &[(&str, &str)]) {
let schema = serde_json::to_value(T::schema()).expect("schema must serialize");
assert_eq!(schema["type"], "object");
let mut required: Vec<String> = schema["required"]
.as_array()
.expect("required must be an array")
.iter()
.map(|value| value.as_str().expect("required entry must be a string").to_string())
.collect();
required.sort();
let mut expected_required: Vec<String> = fields.iter().map(|(name, _)| (*name).to_string()).collect();
expected_required.sort();
assert_eq!(required, expected_required);
for (name, expected_type) in fields {
assert_eq!(schema["properties"][*name]["type"], *expected_type, "field {name} type");
}
}
#[cfg(feature = "api")]
#[test]
fn should_describe_binding_dtos_as_named_object_schemas() {
assert_named_object_schema::<MarkdownLink>(&[("text", "string"), ("url", "string")]);
assert_named_object_schema::<MarkdownCodeBlock>(&[("language", "string"), ("code", "string")]);
assert_named_object_schema::<KeyValueAttribute>(&[("key", "string"), ("value", "string")]);
assert_named_object_schema::<ImageDimensions>(&[("width", "integer"), ("height", "integer")]);
}
#[test]
fn should_serialize_markdown_link_and_code_block_as_named_objects() {
let link = MarkdownLink {
text: "Xberg".into(),
url: "https://xberg.io".into(),
};
let code_block = MarkdownCodeBlock {
language: "rust".into(),
code: "fn main() {}".into(),
};
let metadata = TextMetadata {
line_count: 4,
word_count: 7,
character_count: 42,
headers: None,
links: Some(vec![link.clone()]),
code_blocks: Some(vec![code_block.clone()]),
};
assert_eq!(
serde_json::to_value(&link).expect("link must serialize"),
json!({"text": "Xberg", "url": "https://xberg.io"})
);
assert_eq!(
serde_json::to_value(&code_block).expect("code block must serialize"),
json!({"language": "rust", "code": "fn main() {}"})
);
assert_eq!(
serde_json::to_value(metadata).expect("metadata must serialize"),
json!({
"line_count": 4,
"word_count": 7,
"character_count": 42,
"links": [{"text": "Xberg", "url": "https://xberg.io"}],
"code_blocks": [{"language": "rust", "code": "fn main() {}"}]
})
);
}
#[test]
fn should_still_accept_legacy_positional_array_for_markdown_link_and_code_block() {
let expected_link = MarkdownLink {
text: "Xberg".into(),
url: "https://xberg.io".into(),
};
let expected_code_block = MarkdownCodeBlock {
language: "rust".into(),
code: "fn main() {}".into(),
};
let link: MarkdownLink =
serde_json::from_value(json!(["Xberg", "https://xberg.io"])).expect("legacy link array must deserialize");
let code_block: MarkdownCodeBlock =
serde_json::from_value(json!(["rust", "fn main() {}"])).expect("legacy code block array must deserialize");
assert_eq!(link, expected_link);
assert_eq!(code_block, expected_code_block);
let legacy = json!({
"line_count": 4,
"word_count": 7,
"character_count": 42,
"links": [["Xberg", "https://xberg.io"]],
"code_blocks": [["rust", "fn main() {}"]]
});
let metadata: TextMetadata = serde_json::from_value(legacy).expect("legacy metadata must deserialize");
assert_eq!(metadata.links, Some(vec![expected_link]));
assert_eq!(metadata.code_blocks, Some(vec![expected_code_block]));
}
#[test]
fn should_serialize_key_value_attribute_and_image_dimensions_as_named_objects() {
let attribute = KeyValueAttribute {
key: "role".into(),
value: "button".into(),
};
let dimensions = ImageDimensions {
width: 640,
height: 480,
};
assert_eq!(
serde_json::to_value(attribute).expect("attribute must serialize"),
json!({"key": "role", "value": "button"})
);
assert_eq!(
serde_json::to_value(dimensions).expect("dimensions must serialize"),
json!({"width": 640, "height": 480})
);
}
#[test]
fn should_still_accept_legacy_positional_array_for_attribute_and_dimensions() {
let attribute: KeyValueAttribute =
serde_json::from_value(json!(["role", "button"])).expect("legacy attribute array must deserialize");
let dimensions: ImageDimensions =
serde_json::from_value(json!([640, 480])).expect("legacy dimensions array must deserialize");
let named_attribute: KeyValueAttribute = serde_json::from_value(json!({"key": "role", "value": "button"}))
.expect("named attribute must deserialize");
let named_dimensions: ImageDimensions = serde_json::from_value(json!({"width": 640, "height": 480}))
.expect("named image dimensions must deserialize");
assert_eq!(attribute.key, "role");
assert_eq!(attribute.value, "button");
assert_eq!(dimensions.width, 640);
assert_eq!(dimensions.height, 480);
assert_eq!(named_attribute, attribute);
assert_eq!(named_dimensions, dimensions);
}
}