Skip to main content

odox_core/doc/
sheet.rs

1//! A spreadsheet: `.ods`.
2//!
3//! The body is indexed rather than flattened. ODF writes a run of identical rows
4//! or cells once with a repeat count, and a sheet whose last column says
5//! `table:number-columns-repeated="16384"` is ordinary: expanding that into cells
6//! would turn a small file into a large allocation, and every office application
7//! writes one. So a sheet keeps the rows it was given, each with the range of row
8//! numbers it stands for, and a lookup is a search through those ranges.
9//
10// Author: David M. Anderson
11// Built with AI assistance (Claude, Anthropic)
12
13use super::Document;
14use crate::media_type;
15use crate::value::Length;
16use crate::xml::{Element, Ns};
17use crate::{Error, Family, Properties};
18
19/// An `OpenDocument` spreadsheet.
20pub struct SheetDocument {
21    /// The package and everything shared with the other two formats.
22    pub document: Document,
23    sheets: Vec<Sheet>,
24}
25
26/// One sheet, indexed for lookup by row and column.
27pub struct Sheet {
28    /// The sheet's name, as its tab shows it.
29    pub name: String,
30    /// The columns that carry a width or a default cell style, expanded from the
31    /// column elements' repeat counts and truncated at the last column any cell
32    /// reaches. A column past the end of this list is the default width.
33    pub columns: Vec<Column>,
34    /// The number of rows that carry anything. The grid may show more than this
35    /// and a document ends here.
36    pub used_rows: usize,
37    /// The number of columns that carry anything.
38    pub used_columns: usize,
39    /// Whether the sheet is hidden, from `table:display`.
40    pub visible: bool,
41    rows: Vec<RowRange>,
42    /// Where the `table:table` element sits among the body's children, so that
43    /// the element can be reached again without holding a reference to it.
44    table: usize,
45}
46
47/// A column's own properties.
48pub struct Column {
49    /// Its width, where the column style gives one.
50    pub width: Option<Length>,
51    /// The cell style every cell in the column takes unless it names its own.
52    pub default_cell_style: Option<String>,
53    /// Whether the column is shown.
54    pub visible: bool,
55}
56
57/// A run of rows the document wrote once.
58struct RowRange {
59    /// The first row number this run covers, counting from zero.
60    first: usize,
61    /// How many rows it covers.
62    count: usize,
63    /// Where the `table:table-row` element is, relative to the `table:table`.
64    path: RowPath,
65}
66
67/// Where a row element sits under its table.
68///
69/// Rows are usually the table's own children, and a sheet with a frozen header
70/// or a collapsible outline nests them one or more levels deeper inside
71/// `table:table-header-rows` and `table:table-row-group`. The common case costs
72/// no allocation and the nested case is a path of child indices, which is what
73/// lets a row be reached again without holding a reference into the tree.
74enum RowPath {
75    /// A child of the table.
76    Direct(usize),
77    /// A child of a child of the table, to any depth.
78    Nested(Box<[usize]>),
79}
80
81/// What a cell holds, as ODF types it.
82///
83/// The type is the cell's own declaration and is independent of how it is shown:
84/// a date is a date whatever format it is displayed in, which is what makes a
85/// spreadsheet sortable.
86#[derive(Debug, Clone, PartialEq)]
87pub enum Value {
88    /// Nothing. A cell that exists to carry a style, or to be spanned over.
89    Empty,
90    /// A number.
91    Number(f64),
92    /// A number that means a proportion: `0.15` shown as `15%`.
93    Percentage(f64),
94    /// An amount of money, with the currency code where the cell names one.
95    Currency(f64, Option<String>),
96    /// A date, as written: an ISO 8601 date or date and time.
97    Date(String),
98    /// A duration, as written: an ISO 8601 duration.
99    Time(String),
100    /// True or false.
101    Boolean(bool),
102    /// Text.
103    Text(String),
104}
105
106impl Value {
107    /// Whether the value is one a spreadsheet puts against the right edge of its
108    /// cell: every type but text, which is ODF's own rule and every
109    /// application's default.
110    pub fn is_numeric(&self) -> bool {
111        matches!(
112            self,
113            Self::Number(_)
114                | Self::Percentage(_)
115                | Self::Currency(..)
116                | Self::Date(_)
117                | Self::Time(_)
118        )
119    }
120}
121
122/// One cell, as found in the tree.
123pub struct Cell<'a> {
124    /// The `table:table-cell` element, so that anything not modelled here is
125    /// still reachable.
126    pub element: &'a Element,
127    /// True when this is a `table:covered-table-cell`: a cell hidden underneath
128    /// a neighbour's span. It is kept rather than skipped because the grid has to
129    /// know not to draw a border there.
130    pub covered: bool,
131}
132
133impl Cell<'_> {
134    /// The cell's typed value.
135    pub fn value(&self) -> Value {
136        let e = self.element;
137        match e.attr(&Ns::Office, "value-type") {
138            Some("float") => e
139                .attr(&Ns::Office, "value")
140                .and_then(|v| v.parse().ok())
141                .map_or(Value::Empty, Value::Number),
142            Some("percentage") => e
143                .attr(&Ns::Office, "value")
144                .and_then(|v| v.parse().ok())
145                .map_or(Value::Empty, Value::Percentage),
146            Some("currency") => e
147                .attr(&Ns::Office, "value")
148                .and_then(|v| v.parse().ok())
149                .map_or(Value::Empty, |amount| {
150                    Value::Currency(
151                        amount,
152                        e.attr(&Ns::Office, "currency").map(ToOwned::to_owned),
153                    )
154                }),
155            Some("date") => e
156                .attr(&Ns::Office, "date-value")
157                .map_or(Value::Empty, |v| Value::Date(v.to_owned())),
158            Some("time") => e
159                .attr(&Ns::Office, "time-value")
160                .map_or(Value::Empty, |v| Value::Time(v.to_owned())),
161            Some("boolean") => e
162                .attr(&Ns::Office, "boolean-value")
163                .and_then(crate::value::boolean)
164                .map_or(Value::Empty, Value::Boolean),
165            // A string cell carries its text in its paragraphs, and
166            // `office:string-value` only where the producer chose to write it
167            // there as well.
168            Some("string") => match e.attr(&Ns::Office, "string-value") {
169                Some(text) => Value::Text(text.to_owned()),
170                None => Value::Text(self.text()),
171            },
172            _ => {
173                let text = self.text();
174                if text.is_empty() {
175                    Value::Empty
176                } else {
177                    Value::Text(text)
178                }
179            }
180        }
181    }
182
183    /// What the cell shows: the text the producing application formatted and
184    /// stored in the cell's paragraphs.
185    ///
186    /// This is why a viewer needs neither a number-format engine nor a formula
187    /// evaluator. ODF requires a cell to carry both its typed value and the text
188    /// of that value as the document was last displayed, so the formatted string
189    /// — thousands separators, currency symbol, date order, decimal places — is
190    /// already in the file. A cell edited here would have to be re-formatted
191    /// from its data style, which is the work an editor adds and a viewer does
192    /// not.
193    ///
194    /// A cell with more than one paragraph gives them separated by newlines.
195    pub fn text(&self) -> String {
196        let mut out = String::new();
197        for paragraph in self.element.elements() {
198            if paragraph.is(&Ns::Text, "p") {
199                if !out.is_empty() {
200                    out.push('\n');
201                }
202                out.push_str(&paragraph.plain_text());
203            }
204        }
205        out
206    }
207
208    /// The formula, without the namespace prefix ODF writes in front of it.
209    ///
210    /// A formula is written `of:=SUM([.A1:.A9])`, where the part before the
211    /// colon says which formula language it is in. The prefix is dropped here
212    /// and the expression given as it stands: nothing in this release evaluates
213    /// one, and a formula bar shows what the document says.
214    pub fn formula(&self) -> Option<&str> {
215        let formula = self.element.attr(&Ns::Table, "formula")?;
216        Some(match formula.split_once(":=") {
217            Some((_, expression)) => expression,
218            None => formula,
219        })
220    }
221
222    /// The cell style the cell names, if it names one.
223    pub fn style_name(&self) -> Option<&str> {
224        self.element.attr(&Ns::Table, "style-name")
225    }
226
227    /// How many columns the cell spans, which is one unless it says otherwise.
228    pub fn columns_spanned(&self) -> usize {
229        self.element
230            .attr_usize(&Ns::Table, "number-columns-spanned")
231            .unwrap_or(1)
232            .max(1)
233    }
234
235    /// How many rows the cell spans.
236    pub fn rows_spanned(&self) -> usize {
237        self.element
238            .attr_usize(&Ns::Table, "number-rows-spanned")
239            .unwrap_or(1)
240            .max(1)
241    }
242
243    /// Whether the cell has anything in it: a value, text, or a formula. A cell
244    /// that carries only a style is empty.
245    fn occupied(&self) -> bool {
246        self.element.attr(&Ns::Office, "value-type").is_some()
247            || self.element.attr(&Ns::Table, "formula").is_some()
248            || self.element.elements().any(|e| e.is(&Ns::Text, "p"))
249    }
250}
251
252impl SheetDocument {
253    /// Read a `.ods` package.
254    ///
255    /// # Errors
256    ///
257    /// The bytes are not a spreadsheet, or its `content.xml` cannot be read.
258    pub fn read(bytes: &[u8]) -> Result<Self, Error> {
259        let document = Document::read(bytes, media_type::SPREADSHEET_ANY)?;
260        let sheets = index_sheets(&document);
261        Ok(Self { document, sheets })
262    }
263
264    /// The sheets, in the order the document holds them.
265    pub fn sheets(&self) -> &[Sheet] {
266        &self.sheets
267    }
268
269    /// The `table:table` element of a sheet.
270    fn table(&self, sheet: &Sheet) -> Option<&Element> {
271        let body = self.document.body_of("spreadsheet")?;
272        body.children.get(sheet.table).and_then(|node| match node {
273            crate::xml::Node::Element(e) => Some(e),
274            _ => None,
275        })
276    }
277
278    /// One cell, by sheet, row and column, all counting from zero.
279    ///
280    /// `None` for a cell the document never wrote, which is the usual answer
281    /// past the edge of the used range and means an empty cell rather than an
282    /// error.
283    pub fn cell(&self, sheet: &Sheet, row: usize, column: usize) -> Option<Cell<'_>> {
284        cell_in_row(self.row_element(sheet, row)?, column)
285    }
286
287    /// The `table:table-row` element a row number falls in.
288    pub fn row_element(&self, sheet: &Sheet, row: usize) -> Option<&Element> {
289        let table = self.table(sheet)?;
290        let range = sheet.row_range(row)?;
291        let mut element = table;
292        for step in range.path.steps() {
293            let crate::xml::Node::Element(child) = element.children.get(*step)? else {
294                return None;
295            };
296            element = child;
297        }
298        Some(element)
299    }
300
301    /// A row's height, where its style gives one.
302    pub fn row_height(&self, sheet: &Sheet, row: usize) -> Option<Length> {
303        let name = self
304            .row_element(sheet, row)?
305            .attr(&Ns::Table, "style-name")?;
306        self.document
307            .styles
308            .resolve(&Family::TableRow, name)
309            .row_height
310    }
311
312    /// The resolved style of a cell: the style it names, or the one its column
313    /// gives every cell that names none.
314    pub fn cell_style(
315        &self,
316        sheet: &Sheet,
317        cell: Option<&Cell<'_>>,
318        column: usize,
319    ) -> std::rc::Rc<Properties> {
320        let named = cell.and_then(Cell::style_name);
321        let from_column = sheet
322            .columns
323            .get(column)
324            .and_then(|c| c.default_cell_style.as_deref());
325        let name = named.or(from_column).unwrap_or("Default");
326        self.document.styles.resolve(&Family::TableCell, name)
327    }
328}
329
330impl Sheet {
331    /// The run of rows a row number falls in.
332    fn row_range(&self, row: usize) -> Option<&RowRange> {
333        let found = self
334            .rows
335            .binary_search_by(|range| {
336                if row < range.first {
337                    std::cmp::Ordering::Greater
338                } else if row >= range.first + range.count {
339                    std::cmp::Ordering::Less
340                } else {
341                    std::cmp::Ordering::Equal
342                }
343            })
344            .ok()?;
345        self.rows.get(found)
346    }
347
348    /// The width of a column, where its column style gives one.
349    pub fn column_width(&self, column: usize) -> Option<Length> {
350        self.columns.get(column).and_then(|c| c.width)
351    }
352}
353
354/// Find a cell by column number inside a row, stepping over repeat counts.
355fn cell_in_row(row: &Element, column: usize) -> Option<Cell<'_>> {
356    let mut at = 0usize;
357    for child in row.elements() {
358        let covered = child.is(&Ns::Table, "covered-table-cell");
359        if !covered && !child.is(&Ns::Table, "table-cell") {
360            continue;
361        }
362        let repeat = child
363            .attr_usize(&Ns::Table, "number-columns-repeated")
364            .unwrap_or(1)
365            .max(1);
366        if column < at + repeat {
367            return Some(Cell {
368                element: child,
369                covered,
370            });
371        }
372        at += repeat;
373    }
374    None
375}
376
377/// Build the row and column index of every sheet in the document.
378fn index_sheets(document: &Document) -> Vec<Sheet> {
379    let Some(body) = document.body_of("spreadsheet") else {
380        return Vec::new();
381    };
382    let mut sheets = Vec::new();
383    for (position, node) in body.children.iter().enumerate() {
384        let crate::xml::Node::Element(table) = node else {
385            continue;
386        };
387        if !table.is(&Ns::Table, "table") {
388            continue;
389        }
390        sheets.push(index_sheet(document, table, position));
391    }
392    sheets
393}
394
395fn index_sheet(document: &Document, table: &Element, position: usize) -> Sheet {
396    let mut index = Index {
397        document,
398        rows: Vec::new(),
399        columns: Vec::new(),
400        at_row: 0,
401        used_rows: 0,
402        used_columns: 0,
403    };
404    index.walk(table, &mut Vec::new());
405
406    Sheet {
407        name: table
408            .attr(&Ns::Table, "name")
409            .unwrap_or_default()
410            .to_owned(),
411        columns: index.columns,
412        used_rows: index.used_rows,
413        used_columns: index.used_columns,
414        visible: table.attr(&Ns::Table, "display").unwrap_or("true") != "false",
415        rows: index.rows,
416        table: position,
417    }
418}
419
420/// The state of one sheet's indexing pass.
421struct Index<'a> {
422    document: &'a Document,
423    rows: Vec<RowRange>,
424    columns: Vec<Column>,
425    at_row: usize,
426    used_rows: usize,
427    used_columns: usize,
428}
429
430impl Index<'_> {
431    /// Collect the rows and columns under an element, descending through the
432    /// containers that hold them.
433    ///
434    /// `path` is the route from the table to whatever is being walked, and is the
435    /// route a lookup will take back.
436    fn walk(&mut self, parent: &Element, path: &mut Vec<usize>) {
437        for (child_index, child) in parent.children.iter().enumerate() {
438            let crate::xml::Node::Element(element) = child else {
439                continue;
440            };
441
442            if element.is(&Ns::Table, "table-column") {
443                self.column(element);
444            } else if element.is(&Ns::Table, "table-row") {
445                path.push(child_index);
446                self.row(element, path);
447                path.pop();
448            } else if is_row_container(element) || is_column_container(element) {
449                // A header band or an outline group holds rows and columns that
450                // belong to the sheet as if they were the table's own. The
451                // grouping itself is what a view would draw a collapse handle
452                // for, and the tree still carries it.
453                path.push(child_index);
454                self.walk(element, path);
455                path.pop();
456            }
457        }
458    }
459
460    fn column(&mut self, element: &Element) {
461        let repeat = element
462            .attr_usize(&Ns::Table, "number-columns-repeated")
463            .unwrap_or(1)
464            .max(1);
465        let width = element
466            .attr(&Ns::Table, "style-name")
467            .map(|name| self.document.styles.resolve(&Family::TableColumn, name))
468            .and_then(|p| p.column_width);
469        let default_cell_style = element
470            .attr(&Ns::Table, "default-cell-style-name")
471            .map(ToOwned::to_owned);
472        let visible = element.attr(&Ns::Table, "visibility").unwrap_or("visible") == "visible";
473        // A trailing column run covering the whole sheet is ordinary, and
474        // expanding it is what this index exists to avoid. The run is kept only
475        // as far as ODF permits a column to exist; past that the document is
476        // saying *the rest of the sheet* and the last entry answers for all of it.
477        let keep = repeat.min(MAX_COLUMNS.saturating_sub(self.columns.len()));
478        for _ in 0..keep {
479            self.columns.push(Column {
480                width,
481                default_cell_style: default_cell_style.clone(),
482                visible,
483            });
484        }
485    }
486
487    fn row(&mut self, element: &Element, path: &[usize]) {
488        let repeat = element
489            .attr_usize(&Ns::Table, "number-rows-repeated")
490            .unwrap_or(1)
491            .max(1);
492        if let Some(last) = last_occupied_column(element) {
493            self.used_rows = self.at_row + repeat;
494            self.used_columns = self.used_columns.max(last + 1);
495        }
496        self.rows.push(RowRange {
497            first: self.at_row,
498            count: repeat,
499            path: RowPath::of(path),
500        });
501        self.at_row += repeat;
502    }
503}
504
505impl RowPath {
506    /// The route to a row, taking the cheap form where it is a child of the
507    /// table, which is what a sheet with no grouping gives for every row.
508    fn of(path: &[usize]) -> Self {
509        match path {
510            [only] => Self::Direct(*only),
511            nested => Self::Nested(nested.into()),
512        }
513    }
514
515    /// The child indices to follow, from the table down to the row.
516    fn steps(&self) -> &[usize] {
517        match self {
518            Self::Direct(only) => std::slice::from_ref(only),
519            Self::Nested(path) => path,
520        }
521    }
522}
523
524/// Whether an element holds rows on the sheet's behalf.
525fn is_row_container(element: &Element) -> bool {
526    element.is(&Ns::Table, "table-rows")
527        || element.is(&Ns::Table, "table-header-rows")
528        || element.is(&Ns::Table, "table-row-group")
529}
530
531/// Whether an element holds columns on the sheet's behalf.
532fn is_column_container(element: &Element) -> bool {
533    element.is(&Ns::Table, "table-columns")
534        || element.is(&Ns::Table, "table-header-columns")
535        || element.is(&Ns::Table, "table-column-group")
536}
537
538/// ODF's own column limit, and the point past which a repeat count is a way of
539/// saying *the rest of the sheet*.
540const MAX_COLUMNS: usize = 16_384;
541
542/// The last column in a row that carries anything, or `None` for an empty row.
543fn last_occupied_column(row: &Element) -> Option<usize> {
544    let mut at = 0usize;
545    let mut last = None;
546    for child in row.elements() {
547        let covered = child.is(&Ns::Table, "covered-table-cell");
548        if !covered && !child.is(&Ns::Table, "table-cell") {
549            continue;
550        }
551        let repeat = child
552            .attr_usize(&Ns::Table, "number-columns-repeated")
553            .unwrap_or(1)
554            .max(1);
555        let cell = Cell {
556            element: child,
557            covered,
558        };
559        if cell.occupied() {
560            last = Some(at + repeat - 1);
561        }
562        at += repeat;
563    }
564    last
565}