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