use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use skald_spec::MediaFormat;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "format", rename_all = "snake_case", deny_unknown_fields)]
pub enum ExtractOutput {
Pdf {
pages: Vec<PdfPage>,
},
Pptx {
slides: Vec<PptxSlide>,
},
Docx {
blocks: Vec<DocxBlock>,
},
Xlsx {
sheets: Vec<XlsxSheet>,
},
Csv {
rows: Vec<Vec<String>>,
},
Json {
value: Value,
},
Text {
text: String,
},
Markdown {
text: String,
},
Html {
text: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct PdfPage {
pub number: u32,
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct PptxSlide {
pub number: u32,
#[serde(deserialize_with = "Option::deserialize")]
#[schemars(required)]
pub title: Option<String>,
pub paragraphs: Vec<String>,
pub tables: Vec<Table>,
pub notes: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Table {
pub rows: Vec<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum DocxBlock {
Paragraph {
text: String,
#[serde(deserialize_with = "Option::deserialize")]
#[schemars(required)]
style: Option<String>,
},
Table {
rows: Vec<Vec<String>>,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct XlsxSheet {
pub name: String,
pub cells: Vec<XlsxCell>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct XlsxCell {
pub address: String,
#[serde(deserialize_with = "Option::deserialize")]
#[schemars(required)]
pub value: Option<CellValue>,
#[serde(deserialize_with = "Option::deserialize")]
#[schemars(required)]
pub formula: Option<String>,
#[serde(deserialize_with = "Option::deserialize")]
#[schemars(required)]
pub error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum CellValue {
String(String),
Number(serde_json::Number),
Boolean(bool),
}
impl ExtractOutput {
#[must_use]
pub fn schema_for(formats: &[MediaFormat]) -> Value {
let mut schema = serde_json::to_value(schemars::schema_for!(Self)).unwrap_or(Value::Null);
if let Some(Value::Array(branches)) = schema.get_mut("oneOf") {
branches.retain(|branch| {
branch
.pointer("/properties/format/enum/0")
.and_then(Value::as_str)
.is_some_and(|tag| formats.iter().any(|format| format.as_str() == tag))
});
}
schema
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn extract_output_round_trips_locked_examples() {
for example in [
json!({"format":"pdf","pages":[{"number":1,"text":"Refunds"}]}),
json!({"format":"pptx","slides":[{"number":1,"title":null,"paragraphs":["a"],"tables":[{"rows":[["x",""]]}],"notes":""}]}),
json!({"format":"docx","blocks":[{"kind":"paragraph","text":"Refund policy","style":"Heading1"},{"kind":"table","rows":[["Window","Days"],["Standard","30"]]}]}),
json!({"format":"xlsx","sheets":[{"name":"Refunds","cells":[{"address":"A1","value":"Days","formula":null,"error":null},{"address":"B2","value":30,"formula":null,"error":null},{"address":"C3","value":null,"formula":"=1/0","error":"#DIV/0!"},{"address":"D4","value":true,"formula":null,"error":null}]}]}),
json!({"format":"csv","rows":[["a","b"],["1","2"]]}),
json!({"format":"json","value":{"nested":[1,null]}}),
json!({"format":"text","text":""}),
json!({"format":"markdown","text":"# Title"}),
json!({"format":"html","text":"<p>x</p>"}),
] {
let output: ExtractOutput =
serde_json::from_value(example.clone()).expect("locked example");
assert_eq!(serde_json::to_value(&output).expect("serialize"), example);
}
}
#[test]
fn extract_output_refuses_missing_and_extra_fields() {
for invalid in [
json!({"format":"pdf","pages":[],"page_count":0}),
json!({"format":"pptx","slides":[{"number":1,"paragraphs":[],"tables":[],"notes":""}]}),
json!({"format":"docx","blocks":[{"kind":"paragraph","text":"x"}]}),
json!({"format":"xlsx","sheets":[{"name":"s","cells":[{"address":"A1","value":1}]}]}),
json!({"format":"doc","blocks":[]}),
] {
assert!(
serde_json::from_value::<ExtractOutput>(invalid.clone()).is_err(),
"{invalid}"
);
}
}
#[test]
fn extract_output_schema_selects_formats() {
let schema = ExtractOutput::schema_for(&[MediaFormat::Pptx, MediaFormat::Png]);
let branches = schema["oneOf"].as_array().expect("oneOf");
assert_eq!(branches.len(), 1);
assert_eq!(branches[0]["properties"]["format"]["enum"], json!(["pptx"]));
assert_eq!(branches[0]["additionalProperties"], json!(false));
let slide = &schema["definitions"]["PptxSlide"];
assert!(
slide["required"]
.as_array()
.expect("required")
.contains(&json!("title"))
);
}
}