Skip to main content

document_svg/document/
spreadsheetml.rs

1//! Bounded Microsoft Office 2003 XML Spreadsheet (SpreadsheetML/XMLSS) previews.
2//!
3//! XMLSS workbooks contain Workbook/Worksheet/Table/Row/Cell/Data elements.
4//! This adapter renders cached cell values and formula presence without
5//! evaluating formulas, macros, external links or workbook actions.
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_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}