1#[cfg(not(feature = "std"))]
33use alloc::{
34 string::{String, ToString},
35 vec::Vec,
36};
37
38use crate::sexp::SExpr;
39
40#[derive(Debug, thiserror::Error)]
44#[non_exhaustive]
45pub enum MetadataError {
46 #[error("metadata chunk is not valid UTF-8")]
51 InvalidUtf8,
52}
53
54#[derive(Debug, Clone, Default, PartialEq, Eq)]
62#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
63pub struct DjVuMetadata {
64 pub title: Option<String>,
66 pub author: Option<String>,
68 pub subject: Option<String>,
70 pub publisher: Option<String>,
72 pub year: Option<String>,
74 pub keywords: Option<String>,
76 pub extra: Vec<(String, String)>,
78}
79
80pub fn parse_metadata(data: &[u8]) -> Result<DjVuMetadata, MetadataError> {
87 let text = crate::lenient_text::decode_lossy(data);
91 Ok(parse_metadata_text(&text))
92}
93
94pub fn encode_metadata(meta: &DjVuMetadata) -> Vec<u8> {
106 if meta.title.is_none()
107 && meta.author.is_none()
108 && meta.subject.is_none()
109 && meta.publisher.is_none()
110 && meta.year.is_none()
111 && meta.keywords.is_none()
112 && meta.extra.is_empty()
113 {
114 return Vec::new();
115 }
116
117 let mut out = String::from("(metadata\n");
118 let mut emit = |key: &str, val: &str| {
119 out.push_str(" (");
120 out.push_str(key);
121 out.push_str(" \"");
122 for ch in val.chars() {
123 match ch {
124 '"' => out.push_str("\\\""),
125 '\\' => out.push_str("\\\\"),
126 _ => out.push(ch),
127 }
128 }
129 out.push_str("\")\n");
130 };
131 if let Some(v) = meta.title.as_deref() {
132 emit("title", v);
133 }
134 if let Some(v) = meta.author.as_deref() {
135 emit("author", v);
136 }
137 if let Some(v) = meta.subject.as_deref() {
138 emit("subject", v);
139 }
140 if let Some(v) = meta.publisher.as_deref() {
141 emit("publisher", v);
142 }
143 if let Some(v) = meta.year.as_deref() {
144 emit("year", v);
145 }
146 if let Some(v) = meta.keywords.as_deref() {
147 emit("keywords", v);
148 }
149 for (k, v) in &meta.extra {
150 emit(k, v);
151 }
152 out.push(')');
153 out.into_bytes()
154}
155
156#[cfg(feature = "std")]
159pub fn encode_metadata_bzz(meta: &DjVuMetadata) -> Vec<u8> {
160 let plain = encode_metadata(meta);
161 if plain.is_empty() {
162 return Vec::new();
163 }
164 crate::bzz_encode::bzz_encode(&plain)
165}
166
167fn parse_metadata_text(text: &str) -> DjVuMetadata {
170 let sexprs = crate::sexp::parse_sexprs(text);
171
172 let mut meta = DjVuMetadata::default();
173
174 for expr in &sexprs {
176 if let SExpr::List(items) = expr
177 && let Some(SExpr::Atom(head)) = items.first()
178 {
179 if !head.eq_ignore_ascii_case("metadata") {
180 continue;
181 }
182 for item in &items[1..] {
183 if let SExpr::List(pair) = item
184 && let (Some(SExpr::Atom(key)), Some(SExpr::Atom(val))) =
185 (pair.first(), pair.get(1))
186 {
187 store_kv(&mut meta, key, val);
188 }
189 }
190 }
191 }
192
193 meta
194}
195
196fn store_kv(meta: &mut DjVuMetadata, key: &str, value: &str) {
197 match key.to_lowercase().as_str() {
198 "title" => meta.title = Some(value.to_string()),
199 "author" => meta.author = Some(value.to_string()),
200 "subject" | "description" => meta.subject = Some(value.to_string()),
201 "publisher" => meta.publisher = Some(value.to_string()),
202 "year" | "date" => meta.year = Some(value.to_string()),
203 "keywords" | "keyword" => meta.keywords = Some(value.to_string()),
204 _ => meta.extra.push((key.to_string(), value.to_string())),
205 }
206}
207
208#[cfg(test)]
211#[allow(clippy::field_reassign_with_default)]
212mod tests {
213 use super::*;
214
215 #[test]
216 fn empty_input_returns_default() {
217 let meta = parse_metadata(b"").unwrap();
218 assert_eq!(meta, DjVuMetadata::default());
219 }
220
221 #[test]
222 fn basic_metadata_block() {
223 let text = br#"(metadata (title "My Book") (author "Jane Doe") (year "2023"))"#;
224 let meta = parse_metadata(text).unwrap();
225 assert_eq!(meta.title.as_deref(), Some("My Book"));
226 assert_eq!(meta.author.as_deref(), Some("Jane Doe"));
227 assert_eq!(meta.year.as_deref(), Some("2023"));
228 assert!(meta.subject.is_none());
229 }
230
231 #[test]
232 fn subject_and_keywords() {
233 let text = br#"(metadata (subject "Science") (keywords "physics, chemistry"))"#;
234 let meta = parse_metadata(text).unwrap();
235 assert_eq!(meta.subject.as_deref(), Some("Science"));
236 assert_eq!(meta.keywords.as_deref(), Some("physics, chemistry"));
237 }
238
239 #[test]
240 fn description_alias_maps_to_subject() {
241 let text = br#"(metadata (description "A long description"))"#;
242 let meta = parse_metadata(text).unwrap();
243 assert_eq!(meta.subject.as_deref(), Some("A long description"));
244 }
245
246 #[test]
247 fn date_alias_maps_to_year() {
248 let text = br#"(metadata (date "2020-01-15"))"#;
249 let meta = parse_metadata(text).unwrap();
250 assert_eq!(meta.year.as_deref(), Some("2020-01-15"));
251 }
252
253 #[test]
254 fn extra_keys_go_to_extra_vec() {
255 let text = br#"(metadata (custom-field "value1") (another "value2"))"#;
256 let meta = parse_metadata(text).unwrap();
257 assert_eq!(meta.extra.len(), 2);
258 assert_eq!(
259 meta.extra[0],
260 ("custom-field".to_string(), "value1".to_string())
261 );
262 assert_eq!(meta.extra[1], ("another".to_string(), "value2".to_string()));
263 }
264
265 #[test]
266 fn publisher_field() {
267 let text = br#"(metadata (publisher "Oxford University Press"))"#;
268 let meta = parse_metadata(text).unwrap();
269 assert_eq!(meta.publisher.as_deref(), Some("Oxford University Press"));
270 }
271
272 #[test]
273 fn case_insensitive_keys() {
274 let text = br#"(metadata (TITLE "Upper") (Author "Mixed"))"#;
275 let meta = parse_metadata(text).unwrap();
276 assert_eq!(meta.title.as_deref(), Some("Upper"));
277 assert_eq!(meta.author.as_deref(), Some("Mixed"));
278 }
279
280 #[test]
281 fn escaped_quotes_in_value() {
282 let text = br#"(metadata (title "Book with \"quotes\""))"#;
283 let meta = parse_metadata(text).unwrap();
284 assert_eq!(meta.title.as_deref(), Some(r#"Book with "quotes""#));
285 }
286
287 #[test]
288 fn no_metadata_wrapper_returns_default() {
289 let text = br#"(background #ffffff)"#;
291 let meta = parse_metadata(text).unwrap();
292 assert_eq!(meta, DjVuMetadata::default());
293 }
294
295 #[test]
296 fn multiline_metadata() {
297 let text = b"(metadata\n (title \"Line1\")\n (author \"Line2\")\n)";
298 let meta = parse_metadata(text).unwrap();
299 assert_eq!(meta.title.as_deref(), Some("Line1"));
300 assert_eq!(meta.author.as_deref(), Some("Line2"));
301 }
302
303 #[test]
304 fn invalid_utf8_decodes_leniently() {
305 let invalid = b"\xFF\xFE";
308 let meta = parse_metadata(invalid).unwrap();
309 assert!(meta.title.is_none());
310 assert!(meta.extra.is_empty());
311 }
312
313 #[test]
314 fn cp1252_value_decodes_leniently() {
315 let raw = b"(metadata (title \"Caf\xE9 \x96 Bar\"))";
317 let meta = parse_metadata(raw).unwrap();
318 assert_eq!(meta.title.as_deref(), Some("Caf\u{E9} \u{2013} Bar"));
319 }
320
321 #[test]
322 fn encode_empty_metadata_is_empty() {
323 assert!(encode_metadata(&DjVuMetadata::default()).is_empty());
324 #[cfg(feature = "std")]
325 assert!(encode_metadata_bzz(&DjVuMetadata::default()).is_empty());
326 }
327
328 #[test]
329 fn encode_then_parse_roundtrip_known_fields() {
330 let mut m = DjVuMetadata::default();
331 m.title = Some("Twenty Thousand Leagues".into());
332 m.author = Some("Jules Verne".into());
333 m.year = Some("1870".into());
334 m.keywords = Some("adventure, sea".into());
335 let bytes = encode_metadata(&m);
336 let parsed = parse_metadata(&bytes).unwrap();
337 assert_eq!(parsed, m);
338 }
339
340 #[test]
341 fn encode_preserves_extra_fields_in_order() {
342 let mut m = DjVuMetadata::default();
343 m.extra = vec![
344 ("isbn".into(), "0-553-21311-3".into()),
345 ("language".into(), "en".into()),
346 ];
347 let bytes = encode_metadata(&m);
348 let parsed = parse_metadata(&bytes).unwrap();
349 assert_eq!(parsed.extra, m.extra);
350 }
351
352 #[test]
353 fn encode_escapes_quotes_and_backslashes() {
354 let mut m = DjVuMetadata::default();
355 m.title = Some(r#"He said "hi" \o/"#.into());
356 let bytes = encode_metadata(&m);
357 let parsed = parse_metadata(&bytes).unwrap();
358 assert_eq!(parsed.title, m.title);
359 }
360
361 #[test]
362 fn encode_subject_and_publisher_roundtrip() {
363 let mut m = DjVuMetadata::default();
364 m.subject = Some("Science".into());
365 m.publisher = Some("Acme Press".into());
366 let bytes = encode_metadata(&m);
367 let parsed = parse_metadata(&bytes).unwrap();
368 assert_eq!(parsed.subject, m.subject);
369 assert_eq!(parsed.publisher, m.publisher);
370 }
371
372 #[cfg(feature = "std")]
373 #[test]
374 fn encode_bzz_roundtrip() {
375 let mut m = DjVuMetadata::default();
376 m.title = Some("Compressed".into());
377 m.author = Some("Author X".into());
378 let bytes = encode_metadata_bzz(&m);
379 assert!(!bytes.is_empty());
380 let decoded = crate::bzz::bzz_decode(&bytes).unwrap();
382 let parsed = parse_metadata(&decoded).unwrap();
383 assert_eq!(parsed, m);
384 }
385}