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_EAD_BYTES: u64 = 64 * 1024 * 1024;
16const MAX_EAD_XML_EVENTS: usize = 1_000_000;
17const MAX_EAD_XML_NODES: usize = 500_000;
18const MAX_EAD_XML_DEPTH: usize = 128;
19const MAX_EAD_TEXT_BYTES: usize = 48 * 1024 * 1024;
20const MAX_EAD_ROWS: usize = 200_000;
21const MAX_EAD_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"ead", None);
25 if !root {
26 return false;
27 }
28 let text = String::from_utf8_lossy(bytes).to_ascii_lowercase();
29 text.contains("<archdesc") || text.contains("<eadheader") || text.contains("<c01")
30}
31
32struct EadPageSink<'a> {
33 inner: &'a mut dyn PageConsumer,
34 warnings: &'a [String],
35}
36
37impl PageConsumer for EadPageSink<'_> {
38 fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
39 page.source_format = "ead".into();
40 if page.title.is_empty() {
41 page.title = "EAD archival finding aid".into();
42 }
43 page.description =
44 "EAD finding-aid structure is rendered inertly; digital-object URLs, identifiers 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 title: String,
55 repository: String,
56 components: usize,
57 unit_dates: usize,
58 notes: usize,
59 digital_objects: usize,
60 rows: Vec<Vec<String>>,
61}
62
63pub(crate) fn convert(
64 path: &Path,
65 options: &ConvertOptions,
66 sink: &mut dyn PageConsumer,
67) -> Result<Vec<String>> {
68 let bytes = read_limited_file(
69 path,
70 options.max_input_bytes.min(MAX_EAD_BYTES),
71 "EAD input",
72 )?;
73 let root = parse_xml_tree(
74 &bytes,
75 &XmlLimits {
76 max_events: options.max_xml_events.min(MAX_EAD_XML_EVENTS),
77 max_nodes: MAX_EAD_XML_NODES,
78 max_depth: MAX_EAD_XML_DEPTH,
79 max_text_bytes: MAX_EAD_TEXT_BYTES,
80 },
81 "EAD",
82 )?;
83 if !root.name.eq_ignore_ascii_case("ead") {
84 return Err(Error::InvalidInput("EAD XML root must ead".into()));
85 }
86 let mut summary = Summary::default();
87 if let Some(archdesc) = descendants_named(&root, "archdesc").first().copied() {
88 if let Some(did) = archdesc.children_named("did").next() {
89 summary.title = first_text(did, "unittitle");
90 summary.repository = first_text(did, "repository");
91 summary.unit_dates = count_named(did, "unitdate");
92 }
93 collect_component_children(archdesc, 0, &mut summary)?;
94 }
95 summary.notes = count_named(&root, "note") + count_named(&root, "scopecontent");
96 summary.digital_objects = count_named(&root, "dao") + count_named(&root, "daogrp");
97 if summary.components == 0 && summary.title.is_empty() {
98 return Err(Error::InvalidInput(
99 "EAD document contains no archival description or components".into(),
100 ));
101 }
102 let metadata = format!(
103 "Title: {}\nRepository: {}\nComponents: {}\nUnit dates: {}\nNotes/scope: {}\nDigital objects: {}",
104 display_or_dash(&summary.title),
105 display_or_dash(&summary.repository),
106 summary.components,
107 summary.unit_dates,
108 summary.notes,
109 summary.digital_objects,
110 );
111 let blocks = vec![
112 HtmlBlock::Heading {
113 level: 1,
114 text: "EAD archival finding aid".into(),
115 },
116 HtmlBlock::Paragraph { text: metadata },
117 HtmlBlock::Table(TableData {
118 headers: vec![
119 "Kind".into(),
120 "Level".into(),
121 "Title".into(),
122 "Detail".into(),
123 ],
124 rows: summary.rows,
125 alignments: vec![TableAlign::Left; 4],
126 raw_source: String::new(),
127 }),
128 ];
129 let warnings = vec![
130 "EAD finding-aid titles, dates, levels and component counts are shown; identifiers, URLs, digital-object paths and descriptive payloads are omitted or redacted".into(),
131 "EAD XML traversal and rendered rows are bounded; external entities, XInclude, images, scripts and linked archival resources are never opened".into(),
132 ];
133 let mut page_sink = EadPageSink {
134 inner: sink,
135 warnings: &warnings,
136 };
137 render_blocks_to_pages(&blocks, &mut page_sink, options)?;
138 Ok(warnings)
139}
140
141fn collect_component_children(
142 element: &XmlElement,
143 depth: usize,
144 summary: &mut Summary,
145) -> Result<()> {
146 for child in &element.children {
147 if is_component(&child.name) {
148 summary.components = summary.components.saturating_add(1);
149 let level = child.attribute("level").unwrap_or(child.name.as_str());
150 let did = child.children_named("did").next();
151 let title = did
152 .map(|did| first_text(did, "unittitle"))
153 .unwrap_or_default();
154 let date = did
155 .map(|did| first_text(did, "unitdate"))
156 .unwrap_or_default();
157 push_row(
158 summary,
159 "component",
160 depth.saturating_add(1),
161 &title,
162 format!("{} {}", level, display_or_dash(&date)),
163 )?;
164 collect_component_children(child, depth.saturating_add(1), summary)?;
165 } else {
166 collect_component_children(child, depth, summary)?;
167 }
168 }
169 Ok(())
170}
171
172fn is_component(name: &str) -> bool {
173 name == "c"
174 || (name.len() == 3
175 && name.starts_with('c')
176 && name[1..].chars().all(|ch| ch.is_ascii_digit()))
177}
178
179fn count_named(element: &XmlElement, name: &str) -> usize {
180 element
181 .children
182 .iter()
183 .map(|child| usize::from(child.name.eq_ignore_ascii_case(name)) + count_named(child, name))
184 .sum()
185}
186
187fn descendants_named<'a>(element: &'a XmlElement, name: &str) -> Vec<&'a XmlElement> {
188 let mut result = Vec::new();
189 for child in &element.children {
190 if child.name.eq_ignore_ascii_case(name) {
191 result.push(child);
192 }
193 result.extend(descendants_named(child, name));
194 }
195 result
196}
197
198fn first_text(parent: &XmlElement, name: &str) -> String {
199 parent
200 .children_named(name)
201 .next()
202 .map(|child| safe_text(child.text.trim()))
203 .unwrap_or_default()
204}
205
206fn safe_text(value: &str) -> String {
207 if value.contains("://") {
208 "[URL omitted]".into()
209 } else {
210 truncate(value)
211 }
212}
213
214fn push_row(
215 summary: &mut Summary,
216 kind: &str,
217 level: usize,
218 title: &str,
219 detail: String,
220) -> Result<()> {
221 if summary.rows.len() >= MAX_EAD_ROWS {
222 return Err(Error::LimitExceeded(format!(
223 "EAD rendered rows exceed {MAX_EAD_ROWS}"
224 )));
225 }
226 summary.rows.push(vec![
227 truncate(kind),
228 level.to_string(),
229 truncate(title),
230 truncate(&detail),
231 ]);
232 Ok(())
233}
234
235fn truncate(value: &str) -> String {
236 if value.len() <= MAX_EAD_DISPLAY_BYTES {
237 return value.to_owned();
238 }
239 let mut end = MAX_EAD_DISPLAY_BYTES;
240 while !value.is_char_boundary(end) {
241 end -= 1;
242 }
243 format!("{}…", &value[..end])
244}
245
246fn display_or_dash(value: &str) -> &str {
247 if value.is_empty() { "—" } else { value }
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 #[test]
255 fn recognizes_ead_roots() {
256 assert!(looks_like_prefix(br#"<ead xmlns="urn:isbn:1-931666-22-9"><archdesc><did><unittitle>Collection</unittitle></did></archdesc></ead>"#));
257 assert!(!looks_like_prefix(br#"<ead><control/></ead>"#));
258 }
259
260 #[test]
261 fn counts_components_and_redacts_urls() {
262 let xml = br#"<ead><archdesc><did><unittitle>Collection</unittitle><repository>Archive</repository></did><dsc><c01 level="series"><did><unittitle>Series One</unittitle><unitdate>1900-1901</unitdate></did><dao href="https://private.example.invalid/a"/></c01></dsc></archdesc></ead>"#;
263 let root = parse_xml_tree(
264 xml,
265 &XmlLimits {
266 max_events: 1000,
267 max_nodes: 1000,
268 max_depth: 32,
269 max_text_bytes: 10000,
270 },
271 "EAD",
272 )
273 .unwrap();
274 let mut summary = Summary::default();
275 let archdesc = root.children_named("archdesc").next().unwrap();
276 summary.title = first_text(archdesc.children_named("did").next().unwrap(), "unittitle");
277 collect_component_children(archdesc, 0, &mut summary).unwrap();
278 assert_eq!(summary.components, 1);
279 assert_eq!(summary.title, "Collection");
280 }
281}