document_svg/document/
spreadsheetml.rs1use 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_XMLSS_BYTES: u64 = 64 * 1024 * 1024;
16const MAX_XMLSS_XML_EVENTS: usize = 1_000_000;
17const MAX_XMLSS_XML_NODES: usize = 500_000;
18const MAX_XMLSS_XML_DEPTH: usize = 96;
19const MAX_XMLSS_TEXT_BYTES: usize = 48 * 1024 * 1024;
20const MAX_XMLSS_ROWS: usize = 200_000;
21const MAX_XMLSS_DISPLAY_BYTES: usize = 512;
22const XMLSS_NAMESPACE: &str = "urn:schemas-microsoft-com:office:spreadsheet";
23
24pub(crate) fn looks_like_prefix(bytes: &[u8]) -> bool {
25 crate::geospatial::xml_tree::looks_like_root(bytes, b"Workbook", None)
26 && String::from_utf8_lossy(bytes)
27 .to_ascii_lowercase()
28 .contains(XMLSS_NAMESPACE)
29}
30
31struct XmlssPageSink<'a> {
32 inner: &'a mut dyn PageConsumer,
33 warnings: &'a [String],
34}
35impl PageConsumer for XmlssPageSink<'_> {
36 fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
37 page.source_format = "spreadsheetml".into();
38 if page.title.is_empty() {
39 page.title = "SpreadsheetML 2003 workbook".into();
40 }
41 page.description = "Microsoft Office 2003 XML Spreadsheet values are rendered as a bounded inert table; formulas, macros and external links are not executed".into();
42 for warning in self.warnings {
43 page.warn(warning.clone());
44 }
45 self.inner.consume(page)
46 }
47}
48
49#[derive(Default)]
50struct Summary {
51 sheets: usize,
52 rows: usize,
53 cells: usize,
54 formulas: usize,
55 rows_out: Vec<Vec<String>>,
56}
57
58pub(crate) fn convert(
59 path: &Path,
60 options: &ConvertOptions,
61 sink: &mut dyn PageConsumer,
62) -> Result<Vec<String>> {
63 let bytes = read_limited_file(
64 path,
65 options.max_input_bytes.min(MAX_XMLSS_BYTES),
66 "SpreadsheetML input",
67 )?;
68 let root = parse_xml_tree(
69 &bytes,
70 &XmlLimits {
71 max_events: options.max_xml_events.min(MAX_XMLSS_XML_EVENTS),
72 max_nodes: MAX_XMLSS_XML_NODES,
73 max_depth: MAX_XMLSS_XML_DEPTH,
74 max_text_bytes: MAX_XMLSS_TEXT_BYTES,
75 },
76 "SpreadsheetML",
77 )?;
78 if !root.name.eq_ignore_ascii_case("Workbook") {
79 return Err(Error::InvalidInput(
80 "SpreadsheetML root must Workbook".into(),
81 ));
82 }
83 if root
84 .namespace
85 .as_deref()
86 .is_none_or(|namespace| namespace != XMLSS_NAMESPACE)
87 {
88 return Err(Error::InvalidInput(
89 "SpreadsheetML namespace is missing or unsupported".into(),
90 ));
91 }
92 let worksheets: Vec<&XmlElement> = root
93 .children
94 .iter()
95 .filter(|child| child.name.eq_ignore_ascii_case("Worksheet"))
96 .collect();
97 let mut summary = Summary {
98 sheets: worksheets.len(),
99 ..Summary::default()
100 };
101 for worksheet in worksheets {
102 let sheet_name = attr_local(worksheet, "Name")
103 .unwrap_or("[unnamed sheet]")
104 .to_owned();
105 for (row_index, row) in worksheet
106 .children
107 .iter()
108 .filter(|child| child.name.eq_ignore_ascii_case("Table"))
109 .flat_map(|table| {
110 table
111 .children
112 .iter()
113 .filter(|child| child.name.eq_ignore_ascii_case("Row"))
114 })
115 .enumerate()
116 {
117 summary.rows = summary.rows.saturating_add(1);
118 for (cell_index, cell) in row
119 .children
120 .iter()
121 .filter(|child| child.name.eq_ignore_ascii_case("Cell"))
122 .enumerate()
123 {
124 if summary.rows_out.len() >= MAX_XMLSS_ROWS {
125 return Err(Error::LimitExceeded(format!(
126 "SpreadsheetML rows exceed {MAX_XMLSS_ROWS}"
127 )));
128 }
129 summary.cells = summary.cells.saturating_add(1);
130 let data = cell
131 .children
132 .iter()
133 .find(|child| child.name.eq_ignore_ascii_case("Data"));
134 let value = data
135 .map(|node| safe_text(&text_content(node)))
136 .unwrap_or_else(|| "-".into());
137 let data_type = data
138 .and_then(|node| attr_local(node, "Type"))
139 .unwrap_or("unknown");
140 let formula = attr_local(cell, "Formula")
141 .map(|_| "[formula omitted]")
142 .unwrap_or("-");
143 if formula != "-" {
144 summary.formulas = summary.formulas.saturating_add(1);
145 }
146 summary.rows_out.push(vec![
147 sheet_name.clone(),
148 format!("R{}C{}", row_index + 1, cell_index + 1),
149 data_type.to_owned(),
150 value,
151 formula.into(),
152 ]);
153 }
154 }
155 }
156 if summary.sheets == 0 || summary.cells == 0 {
157 return Err(Error::InvalidInput(
158 "SpreadsheetML workbook contains no Worksheet cells".into(),
159 ));
160 }
161 let metadata = format!(
162 "Worksheets: {}\nRows: {}\nCells: {}\nFormula cells: {}",
163 summary.sheets, summary.rows, summary.cells, summary.formulas
164 );
165 let blocks = vec![
166 HtmlBlock::Heading {
167 level: 1,
168 text: "SpreadsheetML 2003 workbook".into(),
169 },
170 HtmlBlock::Paragraph { text: metadata },
171 HtmlBlock::Table(TableData {
172 headers: vec![
173 "Sheet".into(),
174 "Cell".into(),
175 "Type".into(),
176 "Value".into(),
177 "Formula".into(),
178 ],
179 rows: summary.rows_out,
180 alignments: vec![TableAlign::Left; 5],
181 raw_source: String::new(),
182 }),
183 ];
184 let warnings = vec![
185 "SpreadsheetML Worksheet/Table/Row/Cell/Data values and formula presence are shown; styles, macros, named ranges, external links, drawings and workbook metadata are omitted or redacted".into(),
186 "SpreadsheetML XML traversal and rows are bounded; DTD/entities, formula evaluation, external-resource access, scripts and Office application operations never run".into(),
187 ];
188 let mut page_sink = XmlssPageSink {
189 inner: sink,
190 warnings: &warnings,
191 };
192 render_blocks_to_pages(&blocks, &mut page_sink, options)?;
193 Ok(warnings)
194}
195
196fn attr_local<'a>(element: &'a XmlElement, name: &str) -> Option<&'a str> {
197 element.attributes.iter().find_map(|(key, value)| {
198 key.rsplit(':')
199 .next()
200 .filter(|local| local.eq_ignore_ascii_case(name))
201 .map(|_| value.as_str())
202 })
203}
204fn text_content(element: &XmlElement) -> String {
205 let mut parts = Vec::new();
206 if !element.text.trim().is_empty() {
207 parts.push(element.text.trim().to_owned());
208 }
209 for child in &element.children {
210 let value = text_content(child);
211 if !value.is_empty() {
212 parts.push(value);
213 }
214 }
215 parts.join(" ")
216}
217fn safe_text(value: &str) -> String {
218 if value.contains("://") {
219 "[URL omitted]".into()
220 } else {
221 truncate(value.trim())
222 }
223}
224fn truncate(value: &str) -> String {
225 if value.len() <= MAX_XMLSS_DISPLAY_BYTES {
226 value.to_owned()
227 } else {
228 let mut end = MAX_XMLSS_DISPLAY_BYTES;
229 while !value.is_char_boundary(end) {
230 end -= 1;
231 }
232 format!("{}…", &value[..end])
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use super::looks_like_prefix;
239 #[test]
240 fn recognizes_xmlss_namespace() {
241 assert!(looks_like_prefix(br#"<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"><Worksheet/></Workbook>"#));
242 }
243 #[test]
244 fn rejects_generic_workbook() {
245 assert!(!looks_like_prefix(br#"<Workbook><Worksheet/></Workbook>"#));
246 }
247}