1use 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}