Skip to main content

djvu_rs/
metadata.rs

1//! DjVu document metadata parser — phase 4 extension.
2//!
3//! Parses METa (plain text) and METz (BZZ-compressed) metadata chunks into a
4//! structured [`DjVuMetadata`] value.
5//!
6//! ## Key public types
7//!
8//! - [`DjVuMetadata`] — key-value metadata extracted from a DjVu document
9//! - [`MetadataError`] — typed errors from this module
10//!
11//! ## Format notes
12//!
13//! METa/METz encode metadata as an S-expression:
14//!
15//! ```text
16//! (metadata
17//!   (author "Author Name")
18//!   (title "Book Title")
19//!   (subject "Subject")
20//!   (year "2023")
21//!   (keywords "keyword1, keyword2")
22//! )
23//! ```
24//!
25//! This module accepts arbitrary key names; well-known keys populate dedicated
26//! fields while anything else goes into [`DjVuMetadata::extra`].
27//!
28//! [`DjVuMetadata`]: crate::metadata::DjVuMetadata
29//! [`MetadataError`]: crate::metadata::MetadataError
30//! [`DjVuMetadata::extra`]: crate::metadata::DjVuMetadata::extra
31
32#[cfg(not(feature = "std"))]
33use alloc::{
34    string::{String, ToString},
35    vec::Vec,
36};
37
38use crate::sexp::SExpr;
39
40// ---- Error ------------------------------------------------------------------
41
42/// Errors from metadata parsing.
43#[derive(Debug, thiserror::Error)]
44#[non_exhaustive]
45pub enum MetadataError {
46    /// The chunk is not valid UTF-8.
47    ///
48    /// No longer produced since #524: invalid bytes are decoded leniently
49    /// (CP1252 fallback). Kept so matching code keeps compiling.
50    #[error("metadata chunk is not valid UTF-8")]
51    InvalidUtf8,
52}
53
54// ---- Public types -----------------------------------------------------------
55
56/// Key-value metadata extracted from a DjVu document's METa/METz chunk.
57///
58/// Well-known keys populate dedicated fields; everything else is in
59/// [`DjVuMetadata::extra`].  All values are plain strings — the DjVu format
60/// does not define structured types beyond that.
61#[derive(Debug, Clone, Default, PartialEq, Eq)]
62#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
63pub struct DjVuMetadata {
64    /// Document title.
65    pub title: Option<String>,
66    /// Author name(s).
67    pub author: Option<String>,
68    /// Subject or description.
69    pub subject: Option<String>,
70    /// Publisher name.
71    pub publisher: Option<String>,
72    /// Publication year.
73    pub year: Option<String>,
74    /// Comma-separated keywords (raw string as stored).
75    pub keywords: Option<String>,
76    /// All other key-value pairs, in document order.
77    pub extra: Vec<(String, String)>,
78}
79
80// ---- Entry points -----------------------------------------------------------
81
82/// Parse a METa (uncompressed) metadata chunk.
83///
84/// `data` is the raw bytes of the METa chunk (not including the 4-byte chunk
85/// ID or the 4-byte length prefix — just the payload).
86pub fn parse_metadata(data: &[u8]) -> Result<DjVuMetadata, MetadataError> {
87    // Nominally UTF-8; legacy files carry CP1252 bytes in metadata values.
88    // Decoded leniently — metadata is not structural and must not abort
89    // document open over one bad byte (#524).
90    let text = crate::lenient_text::decode_lossy(data);
91    Ok(parse_metadata_text(&text))
92}
93
94// ---- Encoder ----------------------------------------------------------------
95
96/// Encode [`DjVuMetadata`] to METa (uncompressed S-expression) bytes.
97///
98/// Output is a single `(metadata …)` form with each populated dedicated field
99/// followed by [`DjVuMetadata::extra`] entries in document order. Returns an
100/// empty `Vec` when no fields are set — callers should skip emitting a chunk.
101///
102/// Round-trip: `parse_metadata(encode_metadata(m))` recovers `m` for any
103/// metadata produced by [`parse_metadata`], modulo the `subject`/`description`
104/// and `year`/`date` aliasing that the parser collapses to canonical keys.
105pub 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/// Encode [`DjVuMetadata`] to METz (BZZ-compressed) bytes. Returns an empty
157/// `Vec` if `meta` has no populated fields (callers should skip the chunk).
158#[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
167// ---- Internal parsing -------------------------------------------------------
168
169fn parse_metadata_text(text: &str) -> DjVuMetadata {
170    let sexprs = crate::sexp::parse_sexprs(text);
171
172    let mut meta = DjVuMetadata::default();
173
174    // Look for a top-level (metadata ...) list
175    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// ---- Tests ------------------------------------------------------------------
209
210#[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        // If there is no (metadata ...) block, return default
290        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        // Legacy CP1252 bytes must not abort metadata parsing (#524); stray
306        // non-S-expression text simply yields empty metadata.
307        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        // 0xE9 = é, 0x96 = – in CP1252 (#524).
316        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        // METz payload decompresses via the shared BZZ path, then the pure parser.
381        let decoded = crate::bzz::bzz_decode(&bytes).unwrap();
382        let parsed = parse_metadata(&decoded).unwrap();
383        assert_eq!(parsed, m);
384    }
385}