Skip to main content

document_svg/document/
mets.rs

1//! Bounded METS archive-structure previews.
2//!
3//! METS describes digitized objects with file sections and nested structural
4//! maps. This adapter renders those relationships and safe MIME/size metadata
5//! only; FLocat/MDRef/MPTR targets, embedded payloads and external resources
6//! are never dereferenced.
7
8use std::path::Path;
9
10use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
11use crate::document::html::{HtmlBlock, render_blocks_to_pages};
12use crate::error::{Error, Result};
13use crate::geospatial::xml_tree::{XmlElement, XmlLimits, parse_xml_tree};
14use crate::table::{TableAlign, TableData};
15
16const MAX_METS_BYTES: u64 = 64 * 1024 * 1024;
17const MAX_METS_XML_EVENTS: usize = 1_000_000;
18const MAX_METS_XML_NODES: usize = 500_000;
19const MAX_METS_XML_DEPTH: usize = 96;
20const MAX_METS_TEXT_BYTES: usize = 32 * 1024 * 1024;
21const MAX_METS_ROWS: usize = 200_000;
22const MAX_METS_DISPLAY_BYTES: usize = 512;
23
24pub(crate) fn looks_like_prefix(bytes: &[u8]) -> bool {
25    if !crate::geospatial::xml_tree::looks_like_root(bytes, b"mets", None) {
26        return false;
27    }
28    let text = String::from_utf8_lossy(bytes).to_ascii_lowercase();
29    text.contains("<filesec") || text.contains("<structmap")
30}
31
32struct MetsPageSink<'a> {
33    inner: &'a mut dyn PageConsumer,
34    warnings: &'a [String],
35}
36
37impl PageConsumer for MetsPageSink<'_> {
38    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
39        page.source_format = "mets".into();
40        if page.title.is_empty() {
41            page.title = "METS archive structure".into();
42        }
43        page.description =
44            "METS file and structural metadata is rendered inertly; locations, metadata references, payloads and external resources are not resolved".into();
45        for warning in self.warnings {
46            page.warn(warning.clone());
47        }
48        self.inner.consume(page)
49    }
50}
51
52#[derive(Default)]
53struct Summary {
54    label: String,
55    objid: String,
56    file_sections: usize,
57    file_groups: usize,
58    files: usize,
59    struct_maps: usize,
60    divisions: usize,
61    file_pointers: usize,
62    mets_pointers: usize,
63    dmd_sections: usize,
64    amd_sections: usize,
65    struct_links: usize,
66    rows: Vec<Vec<String>>,
67}
68
69pub(crate) fn convert(
70    path: &Path,
71    options: &ConvertOptions,
72    sink: &mut dyn PageConsumer,
73) -> Result<Vec<String>> {
74    let bytes = read_limited_file(
75        path,
76        options.max_input_bytes.min(MAX_METS_BYTES),
77        "METS input",
78    )?;
79    let root = parse_xml_tree(
80        &bytes,
81        &XmlLimits {
82            max_events: options.max_xml_events.min(MAX_METS_XML_EVENTS),
83            max_nodes: MAX_METS_XML_NODES,
84            max_depth: MAX_METS_XML_DEPTH,
85            max_text_bytes: MAX_METS_TEXT_BYTES,
86        },
87        "METS",
88    )?;
89    if !root.name.eq_ignore_ascii_case("mets") {
90        return Err(Error::InvalidInput("METS XML root must be mets".into()));
91    }
92    let mut summary = Summary {
93        label: root
94            .attribute("LABEL")
95            .or_else(|| root.attribute("label"))
96            .unwrap_or("—")
97            .to_owned(),
98        objid: root
99            .attribute("OBJID")
100            .or_else(|| root.attribute("objid"))
101            .unwrap_or("—")
102            .to_owned(),
103        ..Summary::default()
104    };
105    summary.file_sections = count_named(&root, "fileSec");
106    summary.file_groups = count_named(&root, "fileGrp");
107    summary.files = count_named(&root, "file");
108    summary.struct_maps = count_named(&root, "structMap");
109    summary.divisions = count_named(&root, "div");
110    summary.file_pointers = count_named(&root, "fptr");
111    summary.mets_pointers = count_named(&root, "mptr");
112    summary.dmd_sections = count_named(&root, "dmdSec");
113    summary.amd_sections = count_named(&root, "amdSec");
114    summary.struct_links = count_named(&root, "structLink");
115    for map in descendants_named(&root, "structMap") {
116        let map_type = map
117            .attribute("TYPE")
118            .or_else(|| map.attribute("type"))
119            .unwrap_or("—");
120        push_row(
121            &mut summary,
122            "structMap",
123            map_type,
124            "hierarchy".into(),
125            "—".into(),
126        )?;
127        for div in map.children_named("div") {
128            collect_div(div, 1, &mut summary)?;
129        }
130    }
131    for file in descendants_named(&root, "file") {
132        let mime = file
133            .attribute("MIMETYPE")
134            .or_else(|| file.attribute("mimetype"))
135            .unwrap_or("—");
136        let size = file
137            .attribute("SIZE")
138            .or_else(|| file.attribute("size"))
139            .unwrap_or("—");
140        push_row(
141            &mut summary,
142            "file",
143            mime,
144            format!("size {size}"),
145            "—".into(),
146        )?;
147    }
148    if summary.rows.is_empty() {
149        push_row(
150            &mut summary,
151            "METS",
152            "—",
153            "empty structure".into(),
154            "—".into(),
155        )?;
156    }
157    let metadata = format!(
158        "Label: {}\nObject ID: {}\nFile sections: {}\nFile groups: {}\nFiles: {}\nStructural maps: {}\nDivisions: {}\nFile pointers: {}\nMETS pointers: {}\nDescriptive sections: {}\nAdministrative sections: {}\nStructural links: {}",
159        truncate(&summary.label),
160        truncate(&summary.objid),
161        summary.file_sections,
162        summary.file_groups,
163        summary.files,
164        summary.struct_maps,
165        summary.divisions,
166        summary.file_pointers,
167        summary.mets_pointers,
168        summary.dmd_sections,
169        summary.amd_sections,
170        summary.struct_links,
171    );
172    let blocks = vec![
173        HtmlBlock::Heading {
174            level: 1,
175            text: "METS archive structure".into(),
176        },
177        HtmlBlock::Paragraph { text: metadata },
178        HtmlBlock::Table(TableData {
179            headers: vec![
180                "Kind".into(),
181                "Name/Type".into(),
182                "Detail".into(),
183                "Value".into(),
184            ],
185            rows: summary.rows,
186            alignments: vec![TableAlign::Left; 4],
187            raw_source: String::new(),
188        }),
189    ];
190    let warnings = vec![
191        "METS file/structural metadata is shown; FLocat, MDRef, mptr/fptr targets, URLs and embedded payloads are omitted".into(),
192        "METS XML traversal and rendered rows are bounded; no ALTO/image/file resource, script or external entity is opened".into(),
193    ];
194    let mut page_sink = MetsPageSink {
195        inner: sink,
196        warnings: &warnings,
197    };
198    render_blocks_to_pages(&blocks, &mut page_sink, options)?;
199    Ok(warnings)
200}
201
202fn collect_div(element: &XmlElement, depth: usize, summary: &mut Summary) -> Result<()> {
203    let kind = element
204        .attribute("TYPE")
205        .or_else(|| element.attribute("type"))
206        .unwrap_or("div");
207    let label = element
208        .attribute("LABEL")
209        .or_else(|| element.attribute("label"))
210        .unwrap_or("—");
211    let pointers = count_named(element, "fptr") + count_named(element, "mptr");
212    push_row(
213        summary,
214        "div",
215        kind,
216        format!("depth {depth}, {pointers} pointers"),
217        label.to_owned(),
218    )?;
219    for child in element.children_named("div") {
220        collect_div(child, depth.saturating_add(1), summary)?;
221    }
222    Ok(())
223}
224
225fn count_named(element: &XmlElement, name: &str) -> usize {
226    element
227        .children
228        .iter()
229        .map(|child| {
230            usize::from(child.name.eq_ignore_ascii_case(name))
231                .saturating_add(count_named(child, name))
232        })
233        .sum()
234}
235
236fn descendants_named<'a>(element: &'a XmlElement, name: &str) -> Vec<&'a XmlElement> {
237    let mut result = Vec::new();
238    for child in &element.children {
239        if child.name.eq_ignore_ascii_case(name) {
240            result.push(child);
241        }
242        result.extend(descendants_named(child, name));
243    }
244    result
245}
246
247fn push_row(
248    summary: &mut Summary,
249    kind: &str,
250    name: &str,
251    detail: String,
252    value: String,
253) -> Result<()> {
254    if summary.rows.len() >= MAX_METS_ROWS {
255        return Err(Error::LimitExceeded(format!(
256            "METS rendered rows exceed {MAX_METS_ROWS}"
257        )));
258    }
259    summary.rows.push(vec![
260        truncate(kind),
261        truncate(name),
262        truncate(&detail),
263        truncate(&value),
264    ]);
265    Ok(())
266}
267
268fn truncate(value: &str) -> String {
269    if value.len() <= MAX_METS_DISPLAY_BYTES {
270        return value.to_owned();
271    }
272    let mut end = MAX_METS_DISPLAY_BYTES;
273    while !value.is_char_boundary(end) {
274        end -= 1;
275    }
276    format!("{}…", &value[..end])
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn recognizes_mets_structural_root() {
285        assert!(looks_like_prefix(
286            br#"<mets xmlns="http://www.loc.gov/METS/"><fileSec/><structMap TYPE="PHYSICAL"><div/></structMap></mets>"#
287        ));
288        assert!(!looks_like_prefix(br#"<mets><metsHdr/></mets>"#));
289    }
290
291    #[test]
292    fn counts_files_divisions_and_pointers() {
293        let xml = br#"<mets LABEL="Book"><fileSec><fileGrp><file ID="F1" MIMETYPE="image/tiff" SIZE="12"/></fileGrp></fileSec><structMap TYPE="PHYSICAL"><div TYPE="page" LABEL="1"><fptr FILEID="F1"/><div TYPE="ocr"><mptr xlink:href="https://private.example.invalid/ocr"/></div></div></structMap></mets>"#;
294        let root = parse_xml_tree(
295            xml,
296            &XmlLimits {
297                max_events: 1000,
298                max_nodes: 1000,
299                max_depth: 32,
300                max_text_bytes: 10000,
301            },
302            "METS",
303        )
304        .unwrap();
305        assert_eq!(count_named(&root, "file"), 1);
306        assert_eq!(count_named(&root, "div"), 2);
307        assert_eq!(count_named(&root, "fptr"), 1);
308        assert_eq!(count_named(&root, "mptr"), 1);
309    }
310}