Skip to main content

document_svg/document/
marcxml.rs

1//! Bounded MARCXML (MARC 21 in XML) bibliographic-record previews.
2//!
3//! MARCXML records are represented as leader/controlfield/datafield/subfield
4//! elements. This adapter renders field structure and safe text values while
5//! omitting external URL values and never resolving catalog links.
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_MARCXML_BYTES: u64 = 64 * 1024 * 1024;
16const MAX_MARCXML_EVENTS: usize = 1_000_000;
17const MAX_MARCXML_NODES: usize = 500_000;
18const MAX_MARCXML_DEPTH: usize = 96;
19const MAX_MARCXML_TEXT_BYTES: usize = 48 * 1024 * 1024;
20const MAX_MARCXML_ROWS: usize = 200_000;
21const MAX_MARCXML_DISPLAY_BYTES: usize = 512;
22
23pub(crate) fn looks_like_prefix(bytes: &[u8]) -> bool {
24    let text = String::from_utf8_lossy(bytes).to_ascii_lowercase();
25    let root = crate::geospatial::xml_tree::looks_like_root(bytes, b"collection", None)
26        || crate::geospatial::xml_tree::looks_like_root(bytes, b"record", None);
27    root && (text.contains("<datafield")
28        || text.contains("<controlfield")
29        || text.contains("<leader"))
30}
31
32struct MarcPageSink<'a> {
33    inner: &'a mut dyn PageConsumer,
34    warnings: &'a [String],
35}
36
37impl PageConsumer for MarcPageSink<'_> {
38    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
39        page.source_format = "marcxml".into();
40        if page.title.is_empty() {
41            page.title = "MARCXML record".into();
42        }
43        page.description =
44            "MARCXML field and bibliographic metadata is rendered inertly; catalog URLs, 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    records: usize,
55    leaders: usize,
56    controlfields: usize,
57    datafields: usize,
58    subfields: usize,
59    rows: Vec<Vec<String>>,
60}
61
62pub(crate) fn convert(
63    path: &Path,
64    options: &ConvertOptions,
65    sink: &mut dyn PageConsumer,
66) -> Result<Vec<String>> {
67    let bytes = read_limited_file(
68        path,
69        options.max_input_bytes.min(MAX_MARCXML_BYTES),
70        "MARCXML input",
71    )?;
72    let root = parse_xml_tree(
73        &bytes,
74        &XmlLimits {
75            max_events: options.max_xml_events.min(MAX_MARCXML_EVENTS),
76            max_nodes: MAX_MARCXML_NODES,
77            max_depth: MAX_MARCXML_DEPTH,
78            max_text_bytes: MAX_MARCXML_TEXT_BYTES,
79        },
80        "MARCXML",
81    )?;
82    if !root.name.eq_ignore_ascii_case("collection") && !root.name.eq_ignore_ascii_case("record") {
83        return Err(Error::InvalidInput(
84            "MARCXML root must be collection or record".into(),
85        ));
86    }
87    let mut summary = Summary::default();
88    let records = if root.name.eq_ignore_ascii_case("record") {
89        vec![&root]
90    } else {
91        root.children_named("record").collect()
92    };
93    for (index, record) in records.iter().enumerate() {
94        collect_record(record, index + 1, &mut summary)?;
95    }
96    if summary.records == 0 {
97        return Err(Error::InvalidInput(
98            "MARCXML collection contains no records".into(),
99        ));
100    }
101    let metadata = format!(
102        "Records: {}\nLeaders: {}\nControl fields: {}\nData fields: {}\nSubfields: {}",
103        summary.records,
104        summary.leaders,
105        summary.controlfields,
106        summary.datafields,
107        summary.subfields
108    );
109    let blocks = vec![
110        HtmlBlock::Heading {
111            level: 1,
112            text: "MARCXML record preview".into(),
113        },
114        HtmlBlock::Paragraph { text: metadata },
115        HtmlBlock::Table(TableData {
116            headers: vec!["Record".into(), "Tag".into(), "Code".into(), "Value".into()],
117            rows: summary.rows,
118            alignments: vec![
119                TableAlign::Right,
120                TableAlign::Left,
121                TableAlign::Left,
122                TableAlign::Left,
123            ],
124            raw_source: String::new(),
125        }),
126    ];
127    let warnings = vec![
128        "MARCXML tags, indicators and safe text values are shown; identifiers, URL values, catalog links and extension payloads are omitted or redacted".into(),
129        "MARCXML XML traversal and rendered rows are bounded; no catalog, schema, image, script or external entity is resolved".into(),
130    ];
131    let mut page_sink = MarcPageSink {
132        inner: sink,
133        warnings: &warnings,
134    };
135    render_blocks_to_pages(&blocks, &mut page_sink, options)?;
136    Ok(warnings)
137}
138
139fn collect_record(record: &XmlElement, number: usize, summary: &mut Summary) -> Result<()> {
140    summary.records = summary.records.saturating_add(1);
141    if let Some(leader) = record.children_named("leader").next() {
142        summary.leaders = summary.leaders.saturating_add(1);
143        push_row(summary, number, "leader", "—", truncate(leader.text.trim()))?;
144    }
145    for field in &record.children {
146        match field.name.as_str() {
147            "controlfield" => {
148                summary.controlfields = summary.controlfields.saturating_add(1);
149                let tag = field.attribute("tag").unwrap_or("—");
150                push_row(
151                    summary,
152                    number,
153                    tag,
154                    "—",
155                    safe_value(field.text.trim(), tag),
156                )?;
157            }
158            "datafield" => {
159                summary.datafields = summary.datafields.saturating_add(1);
160                let tag = field.attribute("tag").unwrap_or("—");
161                let indicators = format!(
162                    "{}{}",
163                    field.attribute("ind1").unwrap_or("—"),
164                    field.attribute("ind2").unwrap_or("—")
165                );
166                for subfield in field.children_named("subfield") {
167                    summary.subfields = summary.subfields.saturating_add(1);
168                    let code = subfield.attribute("code").unwrap_or("—");
169                    push_row(
170                        summary,
171                        number,
172                        tag,
173                        code,
174                        safe_value(subfield.text.trim(), tag),
175                    )?;
176                }
177                if field.children_named("subfield").next().is_none() {
178                    push_row(
179                        summary,
180                        number,
181                        tag,
182                        indicators.as_str(),
183                        "no subfields".into(),
184                    )?;
185                }
186            }
187            _ => {}
188        }
189    }
190    Ok(())
191}
192
193fn push_row(
194    summary: &mut Summary,
195    record: usize,
196    tag: &str,
197    code: &str,
198    value: String,
199) -> Result<()> {
200    if summary.rows.len() >= MAX_MARCXML_ROWS {
201        return Err(Error::LimitExceeded(format!(
202            "MARCXML rendered rows exceed {MAX_MARCXML_ROWS}"
203        )));
204    }
205    summary.rows.push(vec![
206        record.to_string(),
207        truncate(tag),
208        truncate(code),
209        truncate(&value),
210    ]);
211    Ok(())
212}
213
214fn safe_value(value: &str, tag: &str) -> String {
215    if tag == "856" || value.contains("://") {
216        "[URL omitted]".into()
217    } else {
218        truncate(value)
219    }
220}
221
222fn truncate(value: &str) -> String {
223    if value.len() <= MAX_MARCXML_DISPLAY_BYTES {
224        return value.to_owned();
225    }
226    let mut end = MAX_MARCXML_DISPLAY_BYTES;
227    while !value.is_char_boundary(end) {
228        end -= 1;
229    }
230    format!("{}…", &value[..end])
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn recognizes_marcxml_collection_and_record() {
239        assert!(looks_like_prefix(
240            br#"<collection xmlns="http://www.loc.gov/MARC21/slim"><record><leader>abc</leader><controlfield tag="001">1</controlfield></record></collection>"#
241        ));
242        assert!(!looks_like_prefix(
243            br#"<collection><record><foo/></record></collection>"#
244        ));
245    }
246
247    #[test]
248    fn redacts_catalog_urls() {
249        assert_eq!(
250            safe_value("https://private.example.invalid/item", "856"),
251            "[URL omitted]"
252        );
253        assert_eq!(safe_value("A title", "245"), "A title");
254    }
255}