use std::collections::BTreeMap;
use std::fmt::Write as _;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MetadataOutput {
#[default]
None,
Json,
Frontmatter,
}
impl MetadataOutput {
pub const ACCEPTED: &'static str = "none, json, frontmatter";
pub fn parse(s: &str) -> Option<Self> {
match s
.trim()
.to_ascii_lowercase()
.replace(['_', '-'], "")
.as_str()
{
"none" => Some(Self::None),
"json" => Some(Self::Json),
"frontmatter" => Some(Self::Frontmatter),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::None => "none",
Self::Json => "json",
Self::Frontmatter => "frontmatter",
}
}
pub fn in_json(self) -> bool {
self != Self::None
}
pub fn front_matter(self) -> bool {
self == Self::Frontmatter
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GpsPosition {
pub latitude: f64,
pub longitude: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub altitude: Option<f64>,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct DocumentMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub authors: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subject: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub keywords: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub publisher: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub identifier: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rights: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub date: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub modified: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_modified_by: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub creator_tool: Option<String>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub properties: BTreeMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gps: Option<GpsPosition>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub exif: BTreeMap<String, String>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub audio: BTreeMap<String, String>,
}
impl DocumentMetadata {
pub fn is_empty(&self) -> bool {
*self == Self::default()
}
pub fn to_json(&self) -> serde_json::Value {
serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
}
pub fn from_json(value: &serde_json::Value) -> Self {
serde_json::from_value(value.clone()).unwrap_or_default()
}
pub fn to_front_matter(&self) -> String {
if self.is_empty() {
return String::new();
}
let mut out = String::from("---\n");
fn scalar(out: &mut String, key: &str, value: &Option<String>) {
if let Some(v) = value {
let _ = writeln!(out, "{}: {}", yaml_key(key), yaml_scalar(v));
}
}
scalar(&mut out, "title", &self.title);
yaml_list(&mut out, "authors", &self.authors);
scalar(&mut out, "subject", &self.subject);
scalar(&mut out, "description", &self.description);
yaml_list(&mut out, "keywords", &self.keywords);
scalar(&mut out, "language", &self.language);
scalar(&mut out, "publisher", &self.publisher);
scalar(&mut out, "identifier", &self.identifier);
scalar(&mut out, "rights", &self.rights);
scalar(&mut out, "date", &self.date);
scalar(&mut out, "created", &self.created);
scalar(&mut out, "modified", &self.modified);
scalar(&mut out, "last_modified_by", &self.last_modified_by);
scalar(&mut out, "creator_tool", &self.creator_tool);
yaml_map(&mut out, "properties", &self.properties);
if let Some(gps) = &self.gps {
let _ = write!(
out,
"gps: {{latitude: {}, longitude: {}",
gps.latitude, gps.longitude
);
if let Some(alt) = gps.altitude {
let _ = write!(out, ", altitude: {alt}");
}
out.push_str("}\n");
}
yaml_map(&mut out, "exif", &self.exif);
yaml_map(&mut out, "audio", &self.audio);
out.push_str("---\n");
out
}
}
fn yaml_list(out: &mut String, key: &str, items: &[String]) {
if items.is_empty() {
return;
}
if items
.iter()
.all(|i| yaml_plain(i) && !i.contains([',', '[', ']']))
{
let _ = writeln!(out, "{}: [{}]", yaml_key(key), items.join(", "));
} else {
let _ = writeln!(out, "{}:", yaml_key(key));
for i in items {
let _ = writeln!(out, " - {}", yaml_scalar(i));
}
}
}
fn yaml_map(out: &mut String, key: &str, map: &BTreeMap<String, String>) {
if map.is_empty() {
return;
}
let _ = writeln!(out, "{}:", yaml_key(key));
for (k, v) in map {
let _ = writeln!(out, " {}: {}", yaml_key(k), yaml_scalar(v));
}
}
fn yaml_key(key: &str) -> String {
if !key.is_empty()
&& key
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.')
&& key.starts_with(|c: char| c.is_alphabetic() || c == '_')
{
key.to_string()
} else {
yaml_quote(key)
}
}
fn yaml_plain(s: &str) -> bool {
let starts_ok = s.starts_with(|c: char| c.is_alphabetic());
let clean = !s.contains(['\n', '\r', '\t', '"', '\\', '#', '`', '{', '}'])
&& !s.contains(": ")
&& !s.ends_with(':')
&& !s.ends_with(' ');
let word = s.to_ascii_lowercase();
let reserved = matches!(
word.as_str(),
"true" | "false" | "null" | "yes" | "no" | "on" | "off" | "y" | "n"
);
starts_ok && clean && !reserved
}
fn yaml_scalar(s: &str) -> String {
if yaml_plain(s) {
s.to_string()
} else {
yaml_quote(s)
}
}
fn yaml_quote(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c.is_control() => {
let _ = write!(out, "\\u{:04X}", c as u32);
}
c => out.push(c),
}
}
out.push('"');
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn front_matter_prints_plain_where_safe_and_quotes_the_rest() {
let mut m = DocumentMetadata {
title: Some("Test EPUB Document".into()),
authors: vec!["Test Author".into()],
language: Some("en".into()),
date: Some("2024-12-18".into()),
description: Some("A line: with a colon, and \"quotes\"".into()),
..Default::default()
};
m.exif
.insert("DateTimeOriginal".into(), "2024:01:02 03:04:05".into());
m.properties.insert("revision".into(), "3".into());
m.gps = Some(GpsPosition {
latitude: 48.8566,
longitude: 2.3522,
altitude: None,
});
assert_eq!(
m.to_front_matter(),
"---\n\
title: Test EPUB Document\n\
authors: [Test Author]\n\
description: \"A line: with a colon, and \\\"quotes\\\"\"\n\
language: en\n\
date: \"2024-12-18\"\n\
properties:\n revision: \"3\"\n\
gps: {latitude: 48.8566, longitude: 2.3522}\n\
exif:\n DateTimeOriginal: \"2024:01:02 03:04:05\"\n\
---\n"
);
}
#[test]
fn lists_with_commas_or_quotes_go_block_style() {
let m = DocumentMetadata {
authors: vec!["Smith, John".into(), "Ada".into()],
keywords: vec!["yes".into()],
..Default::default()
};
assert_eq!(
m.to_front_matter(),
"---\nauthors:\n - Smith, John\n - Ada\nkeywords:\n - \"yes\"\n---\n"
);
assert!(DocumentMetadata::default().to_front_matter().is_empty());
assert!(DocumentMetadata::default().is_empty());
}
#[test]
fn json_round_trips_and_leaves_empty_fields_out() {
let m = DocumentMetadata {
title: Some("T".into()),
authors: vec!["A".into()],
..Default::default()
};
let j = m.to_json();
assert_eq!(j, serde_json::json!({"title": "T", "authors": ["A"]}));
assert_eq!(DocumentMetadata::from_json(&j), m);
assert_eq!(
DocumentMetadata::from_json(&serde_json::json!({"title": "T", "unknown": 1})).title,
Some("T".into())
);
}
#[test]
fn the_output_mode_parses_its_spellings() {
assert_eq!(
MetadataOutput::parse("Front-Matter"),
Some(MetadataOutput::Frontmatter)
);
assert_eq!(MetadataOutput::parse("json"), Some(MetadataOutput::Json));
assert_eq!(MetadataOutput::parse("none"), Some(MetadataOutput::None));
assert_eq!(MetadataOutput::parse("yaml"), None);
assert!(
MetadataOutput::Frontmatter.in_json() && MetadataOutput::Frontmatter.front_matter()
);
assert!(MetadataOutput::Json.in_json() && !MetadataOutput::Json.front_matter());
assert!(!MetadataOutput::None.in_json());
}
}