Skip to main content

document_svg/document/
ead.rs

1//! Bounded EAD2/EAD3 archival-finding-aid previews.
2//!
3//! EAD describes archival collections as nested components with descriptive
4//! identification data. This adapter renders safe titles, dates and levels;
5//! URLs, identifiers, digital objects and external resources remain 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_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}