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