Skip to main content

document_svg/document/
mods.rs

1//! Bounded MODS (Metadata Object Description Schema) bibliographic previews.
2//!
3//! MODS carries titles, names, origin, subjects, identifiers and locations.
4//! This adapter renders common descriptive metadata and counts while keeping
5//! authority/value URIs and external resources inert.
6
7use std::path::Path;
8
9use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
10use crate::document::html::{HtmlBlock, render_blocks_to_pages};
11use crate::error::{Error, Result};
12use crate::geospatial::xml_tree::{XmlElement, XmlLimits, parse_xml_tree};
13use crate::table::{TableAlign, TableData};
14
15const MAX_MODS_BYTES: u64 = 64 * 1024 * 1024;
16const MAX_MODS_XML_EVENTS: usize = 1_000_000;
17const MAX_MODS_XML_NODES: usize = 500_000;
18const MAX_MODS_XML_DEPTH: usize = 96;
19const MAX_MODS_TEXT_BYTES: usize = 48 * 1024 * 1024;
20const MAX_MODS_ROWS: usize = 200_000;
21const MAX_MODS_DISPLAY_BYTES: usize = 512;
22
23pub(crate) fn looks_like_prefix(bytes: &[u8]) -> bool {
24    let root = crate::geospatial::xml_tree::looks_like_root(bytes, b"mods", None)
25        || crate::geospatial::xml_tree::looks_like_root(bytes, b"modsCollection", None);
26    if !root {
27        return false;
28    }
29    let text = String::from_utf8_lossy(bytes).to_ascii_lowercase();
30    text.contains("<titleinfo") || text.contains("<datafield") || text.contains("<name")
31}
32
33struct ModsPageSink<'a> {
34    inner: &'a mut dyn PageConsumer,
35    warnings: &'a [String],
36}
37
38impl PageConsumer for ModsPageSink<'_> {
39    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
40        page.source_format = "mods".into();
41        if page.title.is_empty() {
42            page.title = "MODS bibliographic record".into();
43        }
44        page.description =
45            "MODS descriptive metadata is rendered inertly; authority URIs, location URLs and external resources are not resolved".into();
46        for warning in self.warnings {
47            page.warn(warning.clone());
48        }
49        self.inner.consume(page)
50    }
51}
52
53#[derive(Default)]
54struct Summary {
55    records: usize,
56    titles: usize,
57    names: usize,
58    subjects: usize,
59    identifiers: usize,
60    locations: usize,
61    genres: usize,
62    rows: Vec<Vec<String>>,
63}
64
65pub(crate) fn convert(
66    path: &Path,
67    options: &ConvertOptions,
68    sink: &mut dyn PageConsumer,
69) -> Result<Vec<String>> {
70    let bytes = read_limited_file(
71        path,
72        options.max_input_bytes.min(MAX_MODS_BYTES),
73        "MODS input",
74    )?;
75    let root = parse_xml_tree(
76        &bytes,
77        &XmlLimits {
78            max_events: options.max_xml_events.min(MAX_MODS_XML_EVENTS),
79            max_nodes: MAX_MODS_XML_NODES,
80            max_depth: MAX_MODS_XML_DEPTH,
81            max_text_bytes: MAX_MODS_TEXT_BYTES,
82        },
83        "MODS",
84    )?;
85    let records: Vec<&XmlElement> = if root.name.eq_ignore_ascii_case("mods") {
86        vec![&root]
87    } else if root.name.eq_ignore_ascii_case("modsCollection") {
88        root.children_named("mods").collect()
89    } else {
90        return Err(Error::InvalidInput(
91            "MODS XML root must be mods or modsCollection".into(),
92        ));
93    };
94    let mut summary = Summary::default();
95    for (index, record) in records.iter().enumerate() {
96        collect_record(record, index + 1, &mut summary)?;
97    }
98    if summary.records == 0 {
99        return Err(Error::InvalidInput(
100            "MODS collection contains no records".into(),
101        ));
102    }
103    let metadata = format!(
104        "Records: {}\nTitle elements: {}\nName elements: {}\nSubjects: {}\nIdentifiers: {}\nLocations: {}\nGenres: {}",
105        summary.records,
106        summary.titles,
107        summary.names,
108        summary.subjects,
109        summary.identifiers,
110        summary.locations,
111        summary.genres,
112    );
113    let blocks = vec![
114        HtmlBlock::Heading {
115            level: 1,
116            text: "MODS bibliographic record".into(),
117        },
118        HtmlBlock::Paragraph { text: metadata },
119        HtmlBlock::Table(TableData {
120            headers: vec!["No.".into(), "Kind".into(), "Type".into(), "Value".into()],
121            rows: summary.rows,
122            alignments: vec![
123                TableAlign::Right,
124                TableAlign::Left,
125                TableAlign::Left,
126                TableAlign::Left,
127            ],
128            raw_source: String::new(),
129        }),
130    ];
131    let warnings = vec![
132        "MODS titles, names, subjects, origin and structural metadata are shown; authority/value URIs, location URLs, notes and arbitrary extension values are omitted or redacted".into(),
133        "MODS XML traversal and rendered rows are bounded; no catalog, schema, image, script or external resource is resolved".into(),
134    ];
135    let mut page_sink = ModsPageSink {
136        inner: sink,
137        warnings: &warnings,
138    };
139    render_blocks_to_pages(&blocks, &mut page_sink, options)?;
140    Ok(warnings)
141}
142
143fn collect_record(record: &XmlElement, number: usize, summary: &mut Summary) -> Result<()> {
144    summary.records = summary.records.saturating_add(1);
145    for title_info in record.children_named("titleInfo") {
146        summary.titles = summary.titles.saturating_add(1);
147        let title = first_text(title_info, "title");
148        let subtitle = first_text(title_info, "subTitle");
149        let value = if subtitle.is_empty() {
150            title
151        } else {
152            format!("{title}: {subtitle}")
153        };
154        push_element(summary, number, title_info, "title", value)?;
155    }
156    for name in record.children_named("name") {
157        summary.names = summary.names.saturating_add(1);
158        push_element(summary, number, name, "name", first_text(name, "namePart"))?;
159    }
160    for origin in record.children_named("originInfo") {
161        for publisher in origin.children_named("publisher") {
162            push_element(
163                summary,
164                number,
165                publisher,
166                "publisher",
167                truncate(publisher.text.trim()),
168            )?;
169        }
170        for date in origin.children_named("dateIssued") {
171            push_element(
172                summary,
173                number,
174                date,
175                "dateIssued",
176                truncate(date.text.trim()),
177            )?;
178        }
179    }
180    for subject in record.children_named("subject") {
181        summary.subjects = summary.subjects.saturating_add(1);
182        let value = first_text(subject, "topic");
183        push_element(summary, number, subject, "subject", value)?;
184    }
185    for identifier in record.children_named("identifier") {
186        summary.identifiers = summary.identifiers.saturating_add(1);
187        let value = if identifier.text.contains("://") {
188            "[URL omitted]".into()
189        } else {
190            truncate(identifier.text.trim())
191        };
192        push_element(summary, number, identifier, "identifier", value)?;
193    }
194    for location in record.children_named("location") {
195        summary.locations = summary.locations.saturating_add(1);
196        push_element(
197            summary,
198            number,
199            location,
200            "location",
201            "external location omitted".into(),
202        )?;
203    }
204    for genre in record.children_named("genre") {
205        summary.genres = summary.genres.saturating_add(1);
206        push_element(summary, number, genre, "genre", truncate(genre.text.trim()))?;
207    }
208    Ok(())
209}
210
211fn push_element(
212    summary: &mut Summary,
213    record: usize,
214    element: &XmlElement,
215    kind: &str,
216    value: String,
217) -> Result<()> {
218    push_row(
219        summary,
220        record,
221        kind,
222        element.attribute("type").unwrap_or("—"),
223        value,
224    )
225}
226
227fn first_text(parent: &XmlElement, name: &str) -> String {
228    parent
229        .children_named(name)
230        .next()
231        .map(|child| truncate(child.text.trim()))
232        .unwrap_or_default()
233}
234
235fn push_row(
236    summary: &mut Summary,
237    record: usize,
238    kind: &str,
239    type_name: &str,
240    value: String,
241) -> Result<()> {
242    if summary.rows.len() >= MAX_MODS_ROWS {
243        return Err(Error::LimitExceeded(format!(
244            "MODS rendered rows exceed {MAX_MODS_ROWS}"
245        )));
246    }
247    summary.rows.push(vec![
248        record.to_string(),
249        truncate(kind),
250        truncate(type_name),
251        truncate(&value),
252    ]);
253    Ok(())
254}
255
256fn truncate(value: &str) -> String {
257    if value.len() <= MAX_MODS_DISPLAY_BYTES {
258        return value.to_owned();
259    }
260    let mut end = MAX_MODS_DISPLAY_BYTES;
261    while !value.is_char_boundary(end) {
262        end -= 1;
263    }
264    format!("{}…", &value[..end])
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn recognizes_mods_roots() {
273        assert!(looks_like_prefix(
274            br#"<mods xmlns="http://www.loc.gov/mods/v3"><titleInfo><title>Book</title></titleInfo></mods>"#
275        ));
276        assert!(looks_like_prefix(
277            br#"<modsCollection><mods><name><namePart>A</namePart></name></mods></modsCollection>"#
278        ));
279        assert!(!looks_like_prefix(br#"<mods><note>x</note></mods>"#));
280    }
281
282    #[test]
283    fn redacts_identifier_urls() {
284        let xml = br#"<mods><identifier type="uri">https://private.example.invalid/item</identifier></mods>"#;
285        let root = parse_xml_tree(
286            xml,
287            &XmlLimits {
288                max_events: 100,
289                max_nodes: 100,
290                max_depth: 16,
291                max_text_bytes: 1000,
292            },
293            "MODS",
294        )
295        .unwrap();
296        let mut summary = Summary::default();
297        collect_record(&root, 1, &mut summary).unwrap();
298        assert_eq!(summary.rows[0][3], "[URL omitted]");
299    }
300}