Skip to main content

document_svg/document/
dc.rs

1//! Bounded Dublin Core XML metadata previews.
2//!
3//! Simple Dublin Core metadata is common in OAI-PMH, repositories and
4//! digital-library exports. This adapter renders descriptive elements and
5//! counts only; identifiers, relations, rights text and external URLs remain
6//! inert.
7
8use 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_DC_BYTES: u64 = 32 * 1024 * 1024;
17const MAX_DC_XML_EVENTS: usize = 500_000;
18const MAX_DC_XML_NODES: usize = 300_000;
19const MAX_DC_XML_DEPTH: usize = 80;
20const MAX_DC_TEXT_BYTES: usize = 24 * 1024 * 1024;
21const MAX_DC_ROWS: usize = 100_000;
22const MAX_DC_DISPLAY_BYTES: usize = 512;
23
24pub(crate) fn looks_like_prefix(bytes: &[u8]) -> bool {
25    let root = crate::geospatial::xml_tree::looks_like_root(bytes, b"dc", None)
26        || crate::geospatial::xml_tree::looks_like_root(bytes, b"metadata", None);
27    if !root {
28        return false;
29    }
30    let text = String::from_utf8_lossy(bytes).to_ascii_lowercase();
31    let known_namespace = text.contains("purl.org/dc/") || text.contains("openarchives.org/oai");
32    known_namespace && (text.contains(":title") || text.contains("<title"))
33}
34
35struct DcPageSink<'a> {
36    inner: &'a mut dyn PageConsumer,
37    warnings: &'a [String],
38}
39
40impl PageConsumer for DcPageSink<'_> {
41    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
42        page.source_format = "dublin-core".into();
43        if page.title.is_empty() {
44            page.title = "Dublin Core metadata".into();
45        }
46        page.description =
47            "Dublin Core descriptive metadata is rendered inertly; identifiers, rights text and external URLs are not resolved".into();
48        for warning in self.warnings {
49            page.warn(warning.clone());
50        }
51        self.inner.consume(page)
52    }
53}
54
55#[derive(Default)]
56struct Summary {
57    records: usize,
58    fields: usize,
59    title_count: usize,
60    creator_count: usize,
61    subject_count: usize,
62    url_count: usize,
63    rows: Vec<Vec<String>>,
64}
65
66pub(crate) fn convert(
67    path: &Path,
68    options: &ConvertOptions,
69    sink: &mut dyn PageConsumer,
70) -> Result<Vec<String>> {
71    let bytes = read_limited_file(
72        path,
73        options.max_input_bytes.min(MAX_DC_BYTES),
74        "Dublin Core input",
75    )?;
76    let root = parse_xml_tree(
77        &bytes,
78        &XmlLimits {
79            max_events: options.max_xml_events.min(MAX_DC_XML_EVENTS),
80            max_nodes: MAX_DC_XML_NODES,
81            max_depth: MAX_DC_XML_DEPTH,
82            max_text_bytes: MAX_DC_TEXT_BYTES,
83        },
84        "Dublin Core",
85    )?;
86    if !root.name.eq_ignore_ascii_case("dc") && !root.name.eq_ignore_ascii_case("metadata") {
87        return Err(Error::InvalidInput(
88            "Dublin Core XML root must dc or metadata".into(),
89        ));
90    }
91    let records: Vec<&XmlElement> = if root.name.eq_ignore_ascii_case("dc") {
92        vec![&root]
93    } else {
94        descendants_named(&root, "dc")
95    };
96    let records = if records.is_empty() {
97        vec![&root]
98    } else {
99        records
100    };
101    let mut summary = Summary {
102        records: records.len(),
103        ..Summary::default()
104    };
105    for record in records {
106        collect_record(record, &mut summary)?;
107    }
108    if summary.fields == 0 {
109        return Err(Error::InvalidInput(
110            "Dublin Core document contains no metadata elements".into(),
111        ));
112    }
113    let metadata = format!(
114        "Records: {}\nFields: {}\nTitles: {}\nCreators: {}\nSubjects: {}\nURL/identifier fields: {}",
115        summary.records,
116        summary.fields,
117        summary.title_count,
118        summary.creator_count,
119        summary.subject_count,
120        summary.url_count
121    );
122    let blocks = vec![
123        HtmlBlock::Heading {
124            level: 1,
125            text: "Dublin Core metadata".into(),
126        },
127        HtmlBlock::Paragraph { text: metadata },
128        HtmlBlock::Table(TableData {
129            headers: vec!["Record".into(), "Element".into(), "Value".into()],
130            rows: summary.rows,
131            alignments: vec![TableAlign::Right, TableAlign::Left, TableAlign::Left],
132            raw_source: String::new(),
133        }),
134    ];
135    let warnings = vec![
136        "Dublin Core title/creator/subject and descriptive fields are shown; identifiers, relation URLs, rights text, descriptions and extension values are omitted or redacted".into(),
137        "Dublin Core XML traversal and rendered rows are bounded; no OAI-PMH/catalog/network operation runs".into(),
138    ];
139    let mut page_sink = DcPageSink {
140        inner: sink,
141        warnings: &warnings,
142    };
143    render_blocks_to_pages(&blocks, &mut page_sink, options)?;
144    Ok(warnings)
145}
146
147fn collect_record(record: &XmlElement, summary: &mut Summary) -> Result<()> {
148    for child in &record.children {
149        let element = child.name.as_str();
150        if !is_dc_element(element) {
151            continue;
152        }
153        summary.fields = summary.fields.saturating_add(1);
154        match element {
155            "title" => summary.title_count = summary.title_count.saturating_add(1),
156            "creator" => summary.creator_count = summary.creator_count.saturating_add(1),
157            "subject" => summary.subject_count = summary.subject_count.saturating_add(1),
158            "identifier" | "relation" => summary.url_count = summary.url_count.saturating_add(1),
159            _ => {}
160        }
161        let value = if matches!(element, "identifier" | "relation" | "rights")
162            || child.text.contains("://")
163        {
164            "[value omitted]".into()
165        } else if element == "description" {
166            "[description omitted]".into()
167        } else {
168            truncate(child.text.trim())
169        };
170        push_row(summary, summary.records, element, value)?;
171    }
172    Ok(())
173}
174
175fn is_dc_element(name: &str) -> bool {
176    matches!(
177        name,
178        "title"
179            | "creator"
180            | "subject"
181            | "publisher"
182            | "contributor"
183            | "date"
184            | "type"
185            | "format"
186            | "language"
187            | "coverage"
188            | "source"
189            | "identifier"
190            | "relation"
191            | "rights"
192            | "description"
193    )
194}
195
196fn descendants_named<'a>(element: &'a XmlElement, name: &str) -> Vec<&'a XmlElement> {
197    let mut result = Vec::new();
198    for child in &element.children {
199        if child.name.eq_ignore_ascii_case(name) {
200            result.push(child);
201        }
202        result.extend(descendants_named(child, name));
203    }
204    result
205}
206
207fn push_row(summary: &mut Summary, record: usize, element: &str, value: String) -> Result<()> {
208    if summary.rows.len() >= MAX_DC_ROWS {
209        return Err(Error::LimitExceeded(format!(
210            "Dublin Core rows exceed {MAX_DC_ROWS}"
211        )));
212    }
213    summary.rows.push(vec![
214        record.to_string(),
215        truncate(element),
216        truncate(&value),
217    ]);
218    Ok(())
219}
220
221fn truncate(value: &str) -> String {
222    if value.len() <= MAX_DC_DISPLAY_BYTES {
223        return value.to_owned();
224    }
225    let mut end = MAX_DC_DISPLAY_BYTES;
226    while !value.is_char_boundary(end) {
227        end -= 1;
228    }
229    format!("{}…", &value[..end])
230}