Skip to main content

frazaro_core/reflect/
odf.rs

1//! The OpenDocument reader (PORT.8, slice 8e): an `.ods` file opened into
2//! the same [`Source`] as an OOXML package, so that `frazaro reflect`,
3//! `diff`, `audit` and `--cone` read either format and print the same
4//! rows in the same spelling.
5//!
6//! What is read, and from where. The `mimetype` entry says the file is a
7//! spreadsheet (another OpenDocument kind is refused by name). Everything
8//! else comes from `content.xml`, held whole, since it holds every sheet:
9//! the automatic styles, for the table style whose `display` is `false`
10//! (a hidden sheet; OpenDocument has no very-hidden); each `table:table`,
11//! a sheet, remembered as its slice of the part so that a sheet is walked
12//! from its own slice; the named expressions, workbook-wide and
13//! sheet-local, as `name` rows; the database ranges, Calc's named
14//! rectangles with a header row, as `table` rows (the nearest kin of a
15//! Table; the anonymous ones autofilters leave are not); and, at open, the
16//! other files the formulas reach, for `--counts`. Styles, settings, the
17//! manifest and the metadata are never decoded.
18//!
19//! A cell's value is what the file holds: `office:value` as written for a
20//! float, a percentage or a currency; a boolean; a date or a time under
21//! `date`, a time as its ISO duration; a string from its paragraphs, each
22//! `text:p` a line, a `text:s` its spaces, with annotations and drawings
23//! passed over; an error where Calc's `calcext:value-type` says so, or,
24//! lacking that marker, where a formula cell's string is one of Excel's
25//! seven error literals, the gap the format leaves. A repeated row or cell
26//! is read once per repetition, which is what the file says; a matrix
27//! formula is its anchor's, an array anchor, and the cells it spills into
28//! are values.
29//!
30//! A formula's text is OpenFormula (`of:=SUM([.B1:.B2];[Data.A1])`) and is
31//! printed as the formula bar shows it, in Excel and in Calc alike
32//! (`=SUM(B1:B2,Data!A1)`): the namespace prefix dropped; each bracketed
33//! reference spelled in A1, its sheet quoted by the one rule, a sheet
34//! marked absolute with `$` unmarked, two sheets as a 3D span, a source
35//! (`'file:///…/Rates.xlsx'#`) as the file's name in brackets; `;`
36//! between arguments as `,`; the reference operators `~` and `!` as `,`
37//! and a space; `COM.MICROSOFT.` dropped from a function's name. So the
38//! one reference reader, `crate::refers`, reads both formats, and the
39//! goldens keep one spelling.
40
41use std::collections::HashSet;
42
43use super::cursor::{
44    attr, attr_full, attributes_full, skip_element, unescape_entities, Cursor, Event,
45};
46use super::ooxml::{not_a_workbook, step, xml_refused};
47use super::{
48    emit_formula, NameInfo, Row, SheetInfo, SheetStats, Sink, Source, Summary, TableInfo, Value,
49    Visibility,
50};
51use crate::intrinsics::fold;
52use crate::messages::Refusal;
53use crate::refers;
54use crate::sheet::merge::part_text_raw;
55use crate::sheet::zip;
56use crate::sheet::{cell_ref, MAX_COLUMN, MAX_ROW};
57
58const MIMETYPE: &str = "application/vnd.oasis.opendocument.spreadsheet";
59const CONTENT: &str = "content.xml";
60
61/// Whether these bytes are an OpenDocument file: a zip whose `mimetype`
62/// entry says so, or one with a `content.xml` and no workbook part.
63pub fn is_opendocument(bytes: &[u8]) -> bool {
64    let Some(entries) = zip::entries(bytes) else {
65        return false;
66    };
67    if let Some(e) = entries.iter().find(|e| e.name == "mimetype") {
68        if let Some(data) = zip::stored_data(bytes, e) {
69            return data.starts_with(b"application/vnd.oasis.opendocument");
70        }
71        return matches!(
72            part_text_raw(bytes, &entries, "mimetype"),
73            Ok(Some(ref t)) if t.starts_with("application/vnd.oasis.opendocument")
74        );
75    }
76    let has = |n: &str| entries.iter().any(|e| e.name == n);
77    has(CONTENT) && !has("xl/workbook.xml")
78}
79
80/// An OpenDocument spreadsheet opened far enough to walk.
81pub struct Document {
82    label: String,
83    /// `content.xml`, inflated.
84    text: String,
85    sheets: Vec<SheetInfo>,
86    /// Each sheet's slice of the text, from its start tag to its end tag.
87    spans: Vec<(usize, usize)>,
88    names: Vec<NameInfo>,
89    tables: Vec<TableInfo>,
90    books: Vec<String>,
91}
92
93impl std::fmt::Debug for Document {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        f.debug_struct("Document")
96            .field("label", &self.label)
97            .field("sheets", &self.sheets)
98            .field("names", &self.names)
99            .field("tables", &self.tables)
100            .field("books", &self.books)
101            .finish()
102    }
103}
104
105/// What the structure pass found before any sheet is walked.
106struct Structure {
107    /// Each sheet's name, table style and slice.
108    sheets: Vec<(String, Option<String>, usize, usize)>,
109    hidden_styles: HashSet<String>,
110    names: Vec<NameInfo>,
111    /// Each database range's name and target, `Data.A1:Data.B4`.
112    ranges: Vec<(String, String)>,
113    books: Vec<String>,
114}
115
116impl Document {
117    /// The file read far enough to walk; `label` is what a refusal calls
118    /// the file.
119    pub fn open(bytes: &[u8], label: &str) -> Result<Document, Refusal> {
120        let entries = zip::entries(bytes).ok_or_else(|| {
121            not_a_workbook(
122                label,
123                zip::why_not_an_archive(bytes)
124                    .unwrap_or("it is not a zip archive this reader knows"),
125            )
126        })?;
127        let text_of = |name: &str| -> Result<Option<String>, Refusal> {
128            part_text_raw(bytes, &entries, name).map_err(|why| not_a_workbook(label, &why))
129        };
130        if let Some(mime) = text_of("mimetype")? {
131            let mime = mime.trim();
132            if mime != MIMETYPE {
133                let kind = mime.rsplit('.').next().unwrap_or("file");
134                return Err(not_a_workbook(
135                    label,
136                    &format!("it is an OpenDocument {kind} file, not a spreadsheet"),
137                ));
138            }
139        }
140        let text = text_of(CONTENT)?.ok_or_else(|| {
141            not_a_workbook(
142                label,
143                "it has no content.xml part, so it is not an OpenDocument spreadsheet",
144            )
145        })?;
146        let found = structure(&text, label)?;
147        let sheets: Vec<SheetInfo> = found
148            .sheets
149            .iter()
150            .map(|(name, style, _, _)| SheetInfo {
151                name: name.clone(),
152                visibility: match style {
153                    Some(s) if found.hidden_styles.contains(s) => Visibility::Hidden,
154                    _ => Visibility::Visible,
155                },
156                part: CONTENT.to_string(),
157            })
158            .collect();
159        let spans: Vec<(usize, usize)> = found.sheets.iter().map(|s| (s.2, s.3)).collect();
160        let mut tables: Vec<TableInfo> = Vec::new();
161        for (name, target) in &found.ranges {
162            let Some((first, second)) = split_range(target) else {
163                continue;
164            };
165            let (sheet_name, from) = parse_part(first);
166            let (_, to) = parse_part(second);
167            let wanted = fold(&sheet_name);
168            let Some(sheet) = sheets.iter().position(|s| fold(&s.name) == wanted) else {
169                continue;
170            };
171            let range = if to.is_empty() || to == from {
172                from.to_string()
173            } else {
174                format!("{from}:{to}")
175            };
176            tables.push(TableInfo {
177                name: name.clone(),
178                sheet,
179                range,
180                columns: Vec::new(),
181            });
182        }
183        Ok(Document {
184            label: label.to_string(),
185            text,
186            sheets,
187            spans,
188            names: found.names,
189            tables,
190            books: found.books,
191        })
192    }
193}
194
195/// One pass over `content.xml` for its structure: the hidden table styles,
196/// each sheet's name, style and slice, the named expressions (sheet-local
197/// inside a table, workbook-wide outside), the database ranges, and the
198/// files the formulas reach.
199fn structure(text: &str, label: &str) -> Result<Structure, Refusal> {
200    let part = CONTENT;
201    let mut found = Structure {
202        sheets: Vec::new(),
203        hidden_styles: HashSet::new(),
204        names: Vec::new(),
205        ranges: Vec::new(),
206        books: Vec::new(),
207    };
208    let mut cursor = Cursor::new(text);
209    let mut in_styles = false;
210    let mut current_style: Option<String> = None;
211    let mut table_depth = 0usize;
212    let mut current_sheet: Option<usize> = None;
213    loop {
214        let before = cursor.position();
215        let Some(event) = step(&mut cursor, label, part)? else {
216            break;
217        };
218        match event {
219            Event::Start {
220                name: "automatic-styles",
221                empty: false,
222                ..
223            } => in_styles = true,
224            Event::End {
225                name: "automatic-styles",
226            } => in_styles = false,
227            Event::Start {
228                name: "style",
229                attrs,
230                empty,
231            } if in_styles => {
232                if !empty && attr(attrs, "family").as_deref() == Some("table") {
233                    current_style = attr(attrs, "name").map(|c| c.into_owned());
234                }
235            }
236            Event::End { name: "style" } => current_style = None,
237            Event::Start {
238                name: "table-properties",
239                attrs,
240                ..
241            } => {
242                if let Some(style) = &current_style {
243                    if attr(attrs, "display").as_deref() == Some("false") {
244                        found.hidden_styles.insert(style.clone());
245                    }
246                }
247            }
248            Event::Start {
249                name: "table",
250                attrs,
251                empty,
252            } => {
253                if table_depth == 0 {
254                    let name = attr(attrs, "name")
255                        .map(|c| c.into_owned())
256                        .unwrap_or_else(|| format!("Sheet{}", found.sheets.len() + 1));
257                    let style = attr(attrs, "style-name").map(|c| c.into_owned());
258                    found.sheets.push((name, style, before, cursor.position()));
259                    current_sheet = Some(found.sheets.len() - 1);
260                    if empty {
261                        current_sheet = None;
262                        continue;
263                    }
264                }
265                if !empty {
266                    table_depth += 1;
267                }
268            }
269            Event::End { name: "table" } => {
270                table_depth = table_depth.saturating_sub(1);
271                if table_depth == 0 {
272                    if let Some(i) = current_sheet {
273                        found.sheets[i].3 = cursor.position();
274                    }
275                    current_sheet = None;
276                }
277            }
278            Event::Start {
279                name: "named-range",
280                attrs,
281                ..
282            } => {
283                if let Some(name) = attr(attrs, "name") {
284                    let target = attr(attrs, "cell-range-address").unwrap_or_default();
285                    let refers_to = convert_reference(&target, &mut found.books);
286                    found.names.push(NameInfo {
287                        name: name.into_owned(),
288                        refers_to,
289                        sheet: if table_depth > 0 { current_sheet } else { None },
290                        hidden: false,
291                        placeholder: false,
292                    });
293                }
294            }
295            Event::Start {
296                name: "named-expression",
297                attrs,
298                ..
299            } => {
300                if let Some(name) = attr(attrs, "name") {
301                    let expression = attr(attrs, "expression").unwrap_or_default();
302                    let refers_to = formula_to_a1(&expression, &mut found.books);
303                    found.names.push(NameInfo {
304                        name: name.into_owned(),
305                        refers_to: refers_to.trim_start_matches('=').to_string(),
306                        sheet: if table_depth > 0 { current_sheet } else { None },
307                        hidden: false,
308                        placeholder: false,
309                    });
310                }
311            }
312            Event::Start {
313                name: "database-range",
314                attrs,
315                ..
316            } => {
317                if let (Some(name), Some(target)) =
318                    (attr(attrs, "name"), attr(attrs, "target-range-address"))
319                {
320                    if !name.starts_with("__Anonymous") {
321                        found.ranges.push((name.into_owned(), target.into_owned()));
322                    }
323                }
324            }
325            Event::Start {
326                name: "table-cell" | "covered-table-cell",
327                attrs,
328                ..
329            } => {
330                if let Some(formula) = attr(attrs, "formula") {
331                    let _ = formula_to_a1(&formula, &mut found.books);
332                }
333            }
334            _ => {}
335        }
336    }
337    Ok(found)
338}
339
340/// Excel's seven error literals, which an OpenDocument string may hold in
341/// a formula cell where a host could not say "error".
342fn is_error_literal(text: &str) -> bool {
343    matches!(
344        text,
345        "#NULL!" | "#DIV/0!" | "#VALUE!" | "#REF!" | "#NAME?" | "#NUM!" | "#N/A"
346    )
347}
348
349/// One cell of a row as the file gives it, before its position is known.
350struct CellData {
351    repeat: u32,
352    value: Option<Value>,
353    formula: Option<String>,
354    matrix: bool,
355}
356
357/// The text of a `text:p` just opened: its runs, `text:s` as spaces,
358/// `text:tab` and `text:line-break` as what they say, spans and links
359/// transparent, anything else inside passed over.
360fn paragraph_text(cursor: &mut Cursor, label: &str) -> Result<String, Refusal> {
361    let part = CONTENT;
362    let mut out = String::new();
363    let mut depth = 0u32;
364    loop {
365        match step(cursor, label, part)? {
366            None => return Err(xml_refused(label, part, "a paragraph never closes")),
367            Some(Event::Start { name, attrs, empty }) => match name {
368                "s" => {
369                    let n = attr(attrs, "c")
370                        .and_then(|v| v.parse::<usize>().ok())
371                        .unwrap_or(1)
372                        .min(1 << 16);
373                    for _ in 0..n {
374                        out.push(' ');
375                    }
376                    if !empty {
377                        depth += 1;
378                    }
379                }
380                "tab" => {
381                    out.push('\t');
382                    if !empty {
383                        depth += 1;
384                    }
385                }
386                "line-break" => {
387                    out.push('\n');
388                    if !empty {
389                        depth += 1;
390                    }
391                }
392                "span" | "a" | "ruby" | "ruby-base" | "meta" | "meta-field" => {
393                    if !empty {
394                        depth += 1;
395                    }
396                }
397                _ => {
398                    if !empty {
399                        skip_element(cursor).map_err(|why| xml_refused(label, part, why))?;
400                    }
401                }
402            },
403            Some(Event::End { .. }) => {
404                if depth == 0 {
405                    return Ok(out);
406                }
407                depth -= 1;
408            }
409            Some(Event::Text { text, raw }) => {
410                if raw {
411                    out.push_str(text);
412                } else {
413                    out.push_str(&unescape_entities(text));
414                }
415            }
416        }
417    }
418}
419
420/// The paragraphs of a cell just opened, to its end tag, one line each.
421fn cell_text(cursor: &mut Cursor, label: &str) -> Result<String, Refusal> {
422    let part = CONTENT;
423    let mut paragraphs: Vec<String> = Vec::new();
424    loop {
425        match step(cursor, label, part)? {
426            None => return Err(xml_refused(label, part, "a cell never closes")),
427            Some(Event::Start {
428                name: "p", empty, ..
429            }) => {
430                if empty {
431                    paragraphs.push(String::new());
432                } else {
433                    paragraphs.push(paragraph_text(cursor, label)?);
434                }
435            }
436            Some(Event::Start { empty: false, .. }) => {
437                skip_element(cursor).map_err(|why| xml_refused(label, part, why))?;
438            }
439            Some(Event::End { .. }) => return Ok(paragraphs.join("\n")),
440            _ => {}
441        }
442    }
443}
444
445/// A cell from its start tag: what it holds, read as the module says.
446fn read_cell(
447    cursor: &mut Cursor,
448    attrs: &str,
449    empty: bool,
450    label: &str,
451) -> Result<CellData, Refusal> {
452    let repeat = attr(attrs, "number-columns-repeated")
453        .and_then(|v| v.parse::<u32>().ok())
454        .unwrap_or(1)
455        .max(1);
456    let matrix = attr(attrs, "number-matrix-rows-spanned").is_some()
457        || attr(attrs, "number-matrix-columns-spanned").is_some();
458    let formula = attr(attrs, "formula").map(|f| formula_to_a1(&f, &mut Vec::new()));
459    // `office:value-type` beside Calc's `calcext:value-type`: the same
460    // local name, two namespaces.
461    let mut value_type: Option<String> = None;
462    let mut extension_type: Option<String> = None;
463    for (name, value) in attributes_full(attrs) {
464        let local = name.rsplit(':').next().unwrap_or(name);
465        if local == "value-type" {
466            if name.starts_with("calcext:") {
467                extension_type = Some(unescape_entities(value).into_owned());
468            } else {
469                value_type = Some(unescape_entities(value).into_owned());
470            }
471        }
472    }
473    let text = if empty {
474        String::new()
475    } else {
476        cell_text(cursor, label)?
477    };
478    let value = match value_type.as_deref() {
479        Some("float") | Some("percentage") | Some("currency") => {
480            Some(Value::Number(match attr(attrs, "value") {
481                Some(v) => v.trim().to_string(),
482                None => text.trim().to_string(),
483            }))
484        }
485        Some("boolean") => attr(attrs, "boolean-value")
486            .map(|v| Value::Bool(v.trim().eq_ignore_ascii_case("true")))
487            .or_else(|| Some(Value::Bool(text.trim().eq_ignore_ascii_case("true")))),
488        Some("date") => attr(attrs, "date-value").map(|v| Value::Date(v.trim().to_string())),
489        Some("time") => attr(attrs, "time-value").map(|v| Value::Date(v.trim().to_string())),
490        Some("string") => {
491            let text = attr(attrs, "string-value")
492                .map(|v| v.into_owned())
493                .unwrap_or(text);
494            if extension_type.as_deref() == Some("error")
495                || (formula.is_some() && is_error_literal(text.trim()))
496            {
497                Some(Value::Error(text.trim().to_string()))
498            } else {
499                Some(Value::Text(text))
500            }
501        }
502        // Excel's own spelling of an error cell in an .ods (2026-10-05):
503        // a value type the format does not define, with the text beside it.
504        Some("error") => {
505            let text = attr(attrs, "string-value")
506                .map(|v| v.into_owned())
507                .unwrap_or(text);
508            Some(Value::Error(text.trim().to_string()))
509        }
510        Some("void") => None,
511        _ => {
512            if formula.is_none() && !text.is_empty() {
513                Some(Value::Text(text))
514            } else {
515                None
516            }
517        }
518    };
519    let _ = attr_full(attrs, "office:value-type");
520    Ok(CellData {
521        repeat,
522        value,
523        formula,
524        matrix,
525    })
526}
527
528/// A row from its start tag to its end tag, cell by cell.
529fn read_row(cursor: &mut Cursor, label: &str) -> Result<Vec<CellData>, Refusal> {
530    let part = CONTENT;
531    let mut cells: Vec<CellData> = Vec::new();
532    loop {
533        match step(cursor, label, part)? {
534            None => return Err(xml_refused(label, part, "a row never closes")),
535            Some(Event::Start {
536                name: "table-cell" | "covered-table-cell",
537                attrs,
538                empty,
539            }) => cells.push(read_cell(cursor, attrs, empty, label)?),
540            Some(Event::Start { empty: false, .. }) => {
541                skip_element(cursor).map_err(|why| xml_refused(label, part, why))?;
542            }
543            Some(Event::End { name: "table-row" }) => return Ok(cells),
544            _ => {}
545        }
546    }
547}
548
549impl Source for Document {
550    fn label(&self) -> &str {
551        &self.label
552    }
553
554    fn sheets(&self) -> &[SheetInfo] {
555        &self.sheets
556    }
557
558    fn names(&self) -> &[NameInfo] {
559        &self.names
560    }
561
562    fn tables(&self) -> &[TableInfo] {
563        &self.tables
564    }
565
566    fn external_books(&self) -> &[String] {
567        &self.books
568    }
569
570    fn summary(&self) -> Summary {
571        Summary {
572            names: self.names.len(),
573            placeholders: 0,
574            tables: self.tables.len(),
575            external_books: self.books.len(),
576            strings: 0,
577            string_bytes: 0,
578        }
579    }
580
581    fn walk_sheet_with(
582        &self,
583        index: usize,
584        sink: &mut dyn Sink,
585        book_distinct: &mut HashSet<String>,
586    ) -> Result<SheetStats, Refusal> {
587        let part = CONTENT;
588        let label = self.label.as_str();
589        let info = &self.sheets[index];
590        let (start, end) = self.spans[index];
591        let slice = &self.text[start..end];
592        let mut stats = SheetStats {
593            part_bytes: slice.len(),
594            ..SheetStats::default()
595        };
596        let mut sheet_distinct: HashSet<String> = HashSet::new();
597        let mut cursor = Cursor::new(slice);
598        let mut opened = false;
599        let mut row_no: u32 = 0;
600        while let Some(event) = step(&mut cursor, label, part)? {
601            match event {
602                Event::Start {
603                    name: "table",
604                    empty,
605                    ..
606                } if !opened => {
607                    if empty {
608                        break;
609                    }
610                    opened = true;
611                }
612                Event::End { name: "table" } => break,
613                Event::Start {
614                    name: "table-row",
615                    attrs,
616                    empty,
617                } => {
618                    let repeat = attr(attrs, "number-rows-repeated")
619                        .and_then(|v| v.parse::<u32>().ok())
620                        .unwrap_or(1)
621                        .max(1);
622                    if empty {
623                        row_no = row_no.saturating_add(repeat);
624                        continue;
625                    }
626                    let cells = read_row(&mut cursor, label)?;
627                    // Excel's filler below and beside the data is one empty
628                    // row repeated a million times holding one empty cell
629                    // repeated sixteen thousand: nothing in it is held, so
630                    // nothing in it is visited.
631                    if cells
632                        .iter()
633                        .all(|c| c.value.is_none() && c.formula.is_none())
634                    {
635                        row_no = row_no.saturating_add(repeat);
636                        continue;
637                    }
638                    for r in 0..repeat {
639                        let row = row_no.saturating_add(1).saturating_add(r);
640                        if row > MAX_ROW {
641                            break;
642                        }
643                        let mut col: u32 = 0;
644                        for cell in &cells {
645                            if cell.value.is_none() && cell.formula.is_none() {
646                                col = col.saturating_add(cell.repeat);
647                                continue;
648                            }
649                            for c in 0..cell.repeat {
650                                let at = col.saturating_add(1).saturating_add(c);
651                                if at > MAX_COLUMN {
652                                    break;
653                                }
654                                let addr = cell_ref(row, at);
655                                stats.cells += 1;
656                                stats.rows = stats.rows.max(row);
657                                stats.columns = stats.columns.max(at);
658                                if let Some(value) = &cell.value {
659                                    sink.row(&Row::Cell {
660                                        sheet: &info.name,
661                                        addr: &addr,
662                                        value,
663                                    });
664                                }
665                                if let Some(text) = &cell.formula {
666                                    if cell.matrix {
667                                        stats.array_anchors += 1;
668                                    }
669                                    emit_formula(
670                                        sink,
671                                        &info.name,
672                                        &addr,
673                                        text,
674                                        row,
675                                        at,
676                                        &mut stats,
677                                        &mut sheet_distinct,
678                                        book_distinct,
679                                    );
680                                }
681                            }
682                            col = col.saturating_add(cell.repeat);
683                        }
684                    }
685                    row_no = row_no.saturating_add(repeat);
686                }
687                Event::Start {
688                    name: "table-header-rows" | "table-rows" | "table-row-group",
689                    ..
690                } => {}
691                Event::Start { empty: false, .. } => {
692                    skip_element(&mut cursor).map_err(|why| xml_refused(label, part, why))?;
693                }
694                _ => {}
695            }
696        }
697        stats.distinct_r1c1 = sheet_distinct.len() as u64;
698        Ok(stats)
699    }
700}
701
702// ---- OpenFormula to the formula bar's spelling -----------------------------
703
704/// An OpenFormula text as the formula bar spells it; the files its
705/// references reach are added to `books`, each once.
706pub fn formula_to_a1(text: &str, books: &mut Vec<String>) -> String {
707    // The namespace prefix, `of:=` or another writer's, up to its `:=`.
708    let body = match text.find(":=") {
709        Some(i)
710            if text[..i]
711                .chars()
712                .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_')
713                && !text[..i].is_empty() =>
714        {
715            &text[i + 1..]
716        }
717        _ => text,
718    };
719    let chars: Vec<char> = body.chars().collect();
720    let mut out = String::with_capacity(body.len() + 1);
721    if chars.first() != Some(&'=') {
722        out.push('=');
723    }
724    out.push_str(&convert_body(&chars, books));
725    out
726}
727
728/// The index of the `)` that closes the `(` at `open`, strings and
729/// bracketed references passed over; the end when none closes it.
730fn matching_paren(chars: &[char], open: usize) -> usize {
731    let mut depth = 0usize;
732    let mut i = open;
733    while i < chars.len() {
734        match chars[i] {
735            '"' => {
736                i += 1;
737                while i < chars.len() {
738                    if chars[i] == '"' {
739                        if chars.get(i + 1) == Some(&'"') {
740                            i += 2;
741                            continue;
742                        }
743                        break;
744                    }
745                    i += 1;
746                }
747            }
748            '[' => {
749                let mut quoted = false;
750                i += 1;
751                while i < chars.len() {
752                    if chars[i] == '\'' {
753                        quoted = !quoted;
754                    } else if chars[i] == ']' && !quoted {
755                        break;
756                    }
757                    i += 1;
758                }
759            }
760            '(' => depth += 1,
761            ')' => {
762                depth -= 1;
763                if depth == 0 {
764                    return i;
765                }
766            }
767            _ => {}
768        }
769        i += 1;
770    }
771    chars.len()
772}
773
774/// Whether the text at `i` begins with `word`, at a token boundary.
775fn ahead_is(chars: &[char], i: usize, word: &str) -> bool {
776    let at_boundary = i == 0 || !(chars[i - 1].is_ascii_alphanumeric() || chars[i - 1] == '.');
777    at_boundary
778        && chars.len() >= i + word.chars().count()
779        && chars[i..i + word.chars().count()]
780            .iter()
781            .copied()
782            .eq(word.chars())
783}
784
785/// A formula's body, after its `=`, converted; recursive for the argument
786/// of `COM.MICROSOFT.SINGLE`, Excel's spelling of the `@` operator.
787fn convert_body(chars: &[char], books: &mut Vec<String>) -> String {
788    const SINGLE: &str = "COM.MICROSOFT.SINGLE(";
789    const MICROSOFT: &str = "COM.MICROSOFT.";
790    let mut out = String::with_capacity(chars.len());
791    let mut i = 0;
792    while i < chars.len() {
793        let c = chars[i];
794        match c {
795            '"' => {
796                // A string, with its doubled quotes, copied as it stands.
797                out.push('"');
798                i += 1;
799                while i < chars.len() {
800                    out.push(chars[i]);
801                    if chars[i] == '"' {
802                        if chars.get(i + 1) == Some(&'"') {
803                            out.push('"');
804                            i += 2;
805                            continue;
806                        }
807                        i += 1;
808                        break;
809                    }
810                    i += 1;
811                }
812            }
813            '[' => {
814                let mut j = i + 1;
815                let mut quoted = false;
816                while j < chars.len() {
817                    if chars[j] == '\'' {
818                        quoted = !quoted;
819                    } else if chars[j] == ']' && !quoted {
820                        break;
821                    }
822                    j += 1;
823                }
824                let inner: String = chars[i + 1..j.min(chars.len())].iter().collect();
825                out.push_str(&convert_reference(&inner, books));
826                i = j + 1;
827            }
828            '#' => {
829                // An error literal, its `!` or `?` included.
830                let mut j = i + 1;
831                while j < chars.len()
832                    && (chars[j].is_ascii_alphanumeric() || matches!(chars[j], '/' | '?' | '!'))
833                {
834                    j += 1;
835                }
836                out.extend(&chars[i..j]);
837                i = j;
838            }
839            ';' | '~' => {
840                out.push(',');
841                i += 1;
842            }
843            '!' => {
844                out.push(' ');
845                i += 1;
846            }
847            // Excel writes a named expression as `$$Name`; the bar shows `Name`.
848            '$' if chars.get(i + 1) == Some(&'$') => {
849                i += 2;
850            }
851            _ => {
852                // Excel writes the `@` operator, implicit intersection, as
853                // `COM.MICROSOFT.SINGLE(...)`: the bar shows `@` before the
854                // argument. Any other `COM.MICROSOFT.` function keeps its
855                // name without the prefix.
856                if ahead_is(chars, i, SINGLE) {
857                    let open = i + SINGLE.chars().count() - 1;
858                    let close = matching_paren(chars, open);
859                    out.push('@');
860                    out.push_str(&convert_body(
861                        &chars[open + 1..close.min(chars.len())],
862                        books,
863                    ));
864                    i = close + 1;
865                    continue;
866                }
867                if ahead_is(chars, i, MICROSOFT) {
868                    i += MICROSOFT.chars().count();
869                    continue;
870                }
871                out.push(c);
872                i += 1;
873            }
874        }
875    }
876    out
877}
878
879/// The two halves of a range address at the `:` outside quotes, or the
880/// one address and an empty second half.
881fn split_range(inner: &str) -> Option<(&str, &str)> {
882    let mut quoted = false;
883    for (i, c) in inner.char_indices() {
884        match c {
885            '\'' => quoted = !quoted,
886            ':' if !quoted => return Some((&inner[..i], &inner[i + 1..])),
887            _ => {}
888        }
889    }
890    Some((inner, ""))
891}
892
893/// One address of a reference, `$'Q1 Data'.$B$2` or `.A1` or `.B`: the
894/// sheet named, its quotes undone and its `$` dropped (empty when none),
895/// and the cell part as written.
896fn parse_part(part: &str) -> (String, &str) {
897    let part = part.strip_prefix('$').unwrap_or(part);
898    if let Some(rest) = part.strip_prefix('\'') {
899        // A quoted sheet name to its closing quote, `''` an apostrophe.
900        let chars: Vec<(usize, char)> = rest.char_indices().collect();
901        let mut name = String::new();
902        let mut k = 0;
903        while k < chars.len() {
904            let (at, c) = chars[k];
905            if c == '\'' {
906                if chars.get(k + 1).map(|&(_, n)| n) == Some('\'') {
907                    name.push('\'');
908                    k += 2;
909                    continue;
910                }
911                let after = &rest[at + 1..];
912                return (name, after.strip_prefix('.').unwrap_or(after));
913            }
914            name.push(c);
915            k += 1;
916        }
917        return (name, "");
918    }
919    match part.find('.') {
920        Some(i) => (part[..i].to_string(), &part[i + 1..]),
921        None => (String::new(), part),
922    }
923}
924
925/// The last segment of an IRI, percent-decoded: `Rates.xlsx` from
926/// `file:///C:/Models/Rates.xlsx`.
927fn file_name_of(iri: &str) -> String {
928    let tail = iri.rsplit(['/', '\\']).next().unwrap_or(iri);
929    let bytes = tail.as_bytes();
930    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
931    let mut i = 0;
932    while i < bytes.len() {
933        if bytes[i] == b'%' {
934            if let Some(v) = tail
935                .get(i + 1..i + 3)
936                .and_then(|hex| u8::from_str_radix(hex, 16).ok())
937            {
938                out.push(v);
939                i += 3;
940                continue;
941            }
942        }
943        out.push(bytes[i]);
944        i += 1;
945    }
946    String::from_utf8(out).unwrap_or_else(|_| tail.to_string())
947}
948
949/// A bracketed reference's inside, spelled as the formula bar spells it:
950/// `.A1` as `A1`, `Data.A1:.B2` as `Data!A1:B2`, `Model.B1:Scratch.B1` as
951/// `Model:Scratch!B1`, `'Rates.xlsx'#$Sheet1.A1` as `[Rates.xlsx]Sheet1!A1`.
952fn convert_reference(inner: &str, books: &mut Vec<String>) -> String {
953    let mut rest = inner.trim();
954    // A source: a quoted IRI and `#`.
955    let mut book: Option<String> = None;
956    if let Some(after_quote) = rest.strip_prefix('\'') {
957        let mut end: Option<usize> = None;
958        let chars: Vec<(usize, char)> = after_quote.char_indices().collect();
959        let mut k = 0;
960        while k < chars.len() {
961            if chars[k].1 == '\'' {
962                if chars.get(k + 1).map(|&(_, c)| c) == Some('\'') {
963                    k += 2;
964                    continue;
965                }
966                end = Some(chars[k].0);
967                break;
968            }
969            k += 1;
970        }
971        if let Some(e) = end {
972            if after_quote[e + 1..].starts_with('#') {
973                let iri = after_quote[..e].replace("''", "'");
974                let name = file_name_of(&iri);
975                if !books.iter().any(|b| fold(b) == fold(&name)) {
976                    books.push(name.clone());
977                }
978                book = Some(name);
979                rest = &after_quote[e + 2..];
980            }
981        }
982    }
983    let Some((first, second)) = split_range(rest) else {
984        return rest.to_string();
985    };
986    let (sheet1, addr1) = parse_part(first);
987    let (sheet2, addr2) = if second.is_empty() {
988        (String::new(), "")
989    } else {
990        parse_part(second)
991    };
992    let addr = if addr2.is_empty()
993        || (addr2 == addr1 && !sheet2.is_empty() && fold(&sheet2) != fold(&sheet1))
994    {
995        addr1.to_string()
996    } else {
997        format!("{addr1}:{addr2}")
998    };
999    let prefix = if sheet1.is_empty() && sheet2.is_empty() {
1000        String::new()
1001    } else if sheet2.is_empty() || fold(&sheet2) == fold(&sheet1) {
1002        let name = if sheet1.is_empty() { &sheet2 } else { &sheet1 };
1003        match &book {
1004            Some(b) => quote_book_sheet(b, name),
1005            None => format!("{}!", refers::quote_sheet(name)),
1006        }
1007    } else {
1008        // A 3D span: both sheets, quoted together when either needs it.
1009        let span = format!("{sheet1}:{sheet2}");
1010        let needs =
1011            refers::quote_sheet(&sheet1) != sheet1 || refers::quote_sheet(&sheet2) != sheet2;
1012        let spelled = if needs {
1013            format!("'{}'", span.replace('\'', "''"))
1014        } else {
1015            span
1016        };
1017        match &book {
1018            Some(b) => format!("[{b}]{spelled}!"),
1019            None => format!("{spelled}!"),
1020        }
1021    };
1022    format!("{prefix}{addr}")
1023}
1024
1025/// `[Book.xlsx]Sheet!` or, when the sheet needs quoting, `'[Book.xlsx]Sheet 1'!`.
1026fn quote_book_sheet(book: &str, sheet: &str) -> String {
1027    if refers::quote_sheet(sheet) == sheet {
1028        format!("[{book}]{sheet}!")
1029    } else {
1030        format!("'[{book}]{}'!", sheet.replace('\'', "''"))
1031    }
1032}
1033
1034#[cfg(test)]
1035mod tests {
1036    use super::*;
1037    use crate::reflect::{open, reflect_text};
1038
1039    const OFFICE: &str = "xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\" xmlns:style=\"urn:oasis:names:tc:opendocument:xmlns:style:1.0\" xmlns:text=\"urn:oasis:names:tc:opendocument:xmlns:text:1.0\" xmlns:table=\"urn:oasis:names:tc:opendocument:xmlns:table:1.0\" xmlns:of=\"urn:oasis:names:tc:opendocument:xmlns:of:1.2\" xmlns:calcext=\"urn:org:documentfoundation:names:experimental:calc:xmlns:calcext:1.0\" office:version=\"1.2\"";
1040
1041    fn ods(content: &str, mimetype: &str) -> Vec<u8> {
1042        zip::write_stored(&[
1043            ("mimetype".to_string(), mimetype.as_bytes().to_vec()),
1044            (CONTENT.to_string(), content.as_bytes().to_vec()),
1045        ])
1046        .unwrap()
1047    }
1048
1049    #[test]
1050    fn an_openformula_text_reads_as_the_formula_bar_spells_it() {
1051        let mut books = Vec::new();
1052        let cases = [
1053            ("of:=SUM([.B1:.B2])", "=SUM(B1:B2)"),
1054            ("of:=[.B1]*2", "=B1*2"),
1055            ("of:=SUM([Data.B2:Data.B4])", "=SUM(Data!B2:B4)"),
1056            ("of:=SUM([Data.B2:.B4])", "=SUM(Data!B2:B4)"),
1057            (
1058                "of:=['Q1 Data'.B2]+['It''s'.A1]",
1059                "='Q1 Data'!B2+'It''s'!A1",
1060            ),
1061            ("of:=SUM([Model.B1:Scratch.B1])", "=SUM(Model:Scratch!B1)"),
1062            (
1063                "of:=SUM(['Q1 Data'.A1:'Q4 Data'.A1])",
1064                "=SUM('Q1 Data:Q4 Data'!A1)",
1065            ),
1066            ("of:=SUM([Jan.A1:Mar.B2])", "=SUM(Jan:Mar!A1:B2)"),
1067            ("of:=['Rates.xlsx'#$Sheet1.A1]", "=[Rates.xlsx]Sheet1!A1"),
1068            (
1069                "of:=['file:///C:/Models/Rates%20Q1.xlsx'#$'Sheet 1'.A1:.B2]",
1070                "='[Rates Q1.xlsx]Sheet 1'!A1:B2",
1071            ),
1072            ("of:=SUM([.B:.B])", "=SUM(B:B)"),
1073            ("of:=SUM([.1:.3])", "=SUM(1:3)"),
1074            ("of:=INDIRECT(\"B1\")", "=INDIRECT(\"B1\")"),
1075            ("of:=OFFSET([.B1];1;0)", "=OFFSET(B1,1,0)"),
1076            ("of:=COM.MICROSOFT.SEQUENCE(2)", "=SEQUENCE(2)"),
1077            ("of:=[.A1]&\"a;b~c!\"", "=A1&\"a;b~c!\""),
1078            ("of:=\"x\"\"y\"", "=\"x\"\"y\""),
1079            ("of:=[.#REF!]", "=#REF!"),
1080            ("of:=#REF!+#N/A", "=#REF!+#N/A"),
1081            ("of:=SUM([.A1:.A3]~[.C1])", "=SUM(A1:A3,C1)"),
1082            ("of:=SUM([.A1:.B2]![.B1:.C3])", "=SUM(A1:B2 B1:C3)"),
1083            ("oooc:=[.A1]", "=A1"),
1084            ("=A1", "=A1"),
1085            ("A1+1", "=A1+1"),
1086            ("of:=[$Model.$B$2]", "=Model!$B$2"),
1087            ("of:=[Model.B1]+[.B2]*Rate", "=Model!B1+B2*Rate"),
1088            (
1089                "of:=ORG.OPENOFFICE.STYLE(\"Default\")",
1090                "=ORG.OPENOFFICE.STYLE(\"Default\")",
1091            ),
1092            // Excel's own .ods writing (its save of the fixture, 2026-10-05):
1093            // a name as `$$Name` inside `COM.MICROSOFT.SINGLE`, the `@` operator.
1094            ("of:=COM.MICROSOFT.SINGLE($$HiddenName)", "=@HiddenName"),
1095            ("of:=[.B3]*COM.MICROSOFT.SINGLE($$Rate)", "=B3*@Rate"),
1096            (
1097                "of:=COM.MICROSOFT.SINGLE(INDIRECT(\"B1\"))",
1098                "=@INDIRECT(\"B1\")",
1099            ),
1100            (
1101                "of:=COM.MICROSOFT.SINGLE([.A1:.A3])+COM.MICROSOFT.SINGLE($$Deep)",
1102                "=@A1:A3+@Deep",
1103            ),
1104            ("of:=COM.MICROSOFT.SINGLE($$Rate", "=@Rate"),
1105        ];
1106        for (given, want) in cases {
1107            assert_eq!(formula_to_a1(given, &mut books), want, "{given}");
1108        }
1109        assert_eq!(
1110            books,
1111            vec!["Rates.xlsx".to_string(), "Rates Q1.xlsx".to_string()]
1112        );
1113        // A name's refers-to from its range address.
1114        assert_eq!(
1115            convert_reference("$Data.$A$2:.$B$4", &mut books),
1116            "Data!$A$2:$B$4"
1117        );
1118        assert_eq!(convert_reference("$Model.$B$2", &mut books), "Model!$B$2");
1119        assert_eq!(file_name_of("file:///C:/x/y/Rates.xlsx"), "Rates.xlsx");
1120        assert_eq!(file_name_of("Rates.xlsx"), "Rates.xlsx");
1121    }
1122
1123    #[test]
1124    fn a_document_reads_as_the_relations() {
1125        let content = format!(concat!(
1126            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
1127            "<office:document-content {OFFICE}>",
1128            "<office:automatic-styles>",
1129            "<style:style style:name=\"ta1\" style:family=\"table\"><style:table-properties table:display=\"true\"/></style:style>",
1130            "<style:style style:name=\"ta2\" style:family=\"table\"><style:table-properties table:display=\"false\"/></style:style>",
1131            "<style:style style:name=\"ce1\" style:family=\"table-cell\"/>",
1132            "</office:automatic-styles>",
1133            "<office:body><office:spreadsheet>",
1134            "<table:table table:name=\"Model\" table:style-name=\"ta1\">",
1135            "<table:table-column table:number-columns-repeated=\"4\"/>",
1136            "<table:table-row>",
1137            "<table:table-cell office:value-type=\"string\"><text:p>Rate <text:span text:style-name=\"T1\">applies</text:span></text:p></table:table-cell>",
1138            "<table:table-cell office:value-type=\"float\" office:value=\"1200\"><text:p>1200</text:p></table:table-cell>",
1139            "<table:table-cell table:formula=\"of:=[.B1]*2\" office:value-type=\"float\" office:value=\"2400\"><text:p>2400</text:p></table:table-cell>",
1140            "<table:table-cell table:formula=\"of:=1/0\" office:value-type=\"string\" calcext:value-type=\"error\"><text:p>#DIV/0!</text:p></table:table-cell>",
1141            "<table:table-cell table:formula=\"of:=NA()\" office:value-type=\"error\" office:string-value=\"#N/A\"><text:p>#N/A</text:p></table:table-cell>",
1142            "</table:table-row>",
1143            "<table:table-row table:number-rows-repeated=\"2\">",
1144            "<table:table-cell office:value-type=\"boolean\" office:boolean-value=\"true\"><text:p>TRUE</text:p></table:table-cell>",
1145            "<table:table-cell table:style-name=\"ce1\"/>",
1146            "<table:table-cell office:value-type=\"date\" office:date-value=\"2025-09-30\"><text:p>30/09/25</text:p></table:table-cell>",
1147            "<table:table-cell office:value-type=\"time\" office:time-value=\"PT12H30M00S\"><text:p>12:30</text:p></table:table-cell>",
1148            "</table:table-row>",
1149            "<table:table-row>",
1150            "<table:table-cell table:formula=\"of:=[.B1:.B2]*2\" table:number-matrix-columns-spanned=\"1\" table:number-matrix-rows-spanned=\"2\" office:value-type=\"float\" office:value=\"2400\"><text:p>2400</text:p></table:table-cell>",
1151            "<table:table-cell office:value-type=\"string\" table:number-columns-repeated=\"2\"><text:p>a<text:s text:c=\"2\"/>b</text:p><text:p>c</text:p></table:table-cell>",
1152            "<table:table-cell table:formula=\"of:=['Rates.xlsx'#$Sheet1.A1]\" office:value-type=\"string\"><text:p>#REF!</text:p><office:annotation><text:p>a note</text:p></office:annotation></table:table-cell>",
1153            "</table:table-row>",
1154            "<table:named-expressions><table:named-range table:name=\"Local\" table:base-cell-address=\"$Model.$A$1\" table:cell-range-address=\"$Model.$B$1\"/></table:named-expressions>",
1155            "</table:table>",
1156            "<table:table table:name=\"Q1 Data\" table:style-name=\"ta2\">",
1157            "<table:table-row><table:table-cell table:number-columns-repeated=\"2\"/></table:table-row>",
1158            "<table:table-row><table:table-cell/><table:table-cell office:value-type=\"percentage\" office:value=\"0.2\"><text:p>20%</text:p></table:table-cell></table:table-row>",
1159            "</table:table>",
1160            "<table:named-expressions>",
1161            "<table:named-expression table:name=\"Rate\" table:base-cell-address=\"$Model.$A$1\" table:expression=\"of:=0.2\"/>",
1162            "<table:named-range table:name=\"Range1\" table:base-cell-address=\"$Model.$A$1\" table:cell-range-address=\"$'Q1 Data'.$A$2:.$B$2\"/>",
1163            "</table:named-expressions>",
1164            "<table:database-ranges>",
1165            "<table:database-range table:name=\"__Anonymous_Sheet_DB__0\" table:target-range-address=\"Model.A1:Model.D1\"/>",
1166            "<table:database-range table:name=\"Sales\" table:target-range-address=\"'Q1 Data'.A1:'Q1 Data'.B2\"/>",
1167            "</table:database-ranges>",
1168            "</office:spreadsheet></office:body></office:document-content>"), OFFICE = OFFICE);
1169        let bytes = ods(&content, MIMETYPE);
1170        assert!(is_opendocument(&bytes));
1171        let got = reflect_text(&bytes, "t.ods").unwrap();
1172        let want = concat!(
1173            "(sheet \"Model\" visible)\n",
1174            "(sheet \"Q1 Data\" hidden)\n",
1175            "(name \"Model!Local\" \"Model!$B$1\")\n",
1176            "(name \"Range1\" \"'Q1 Data'!$A$2:$B$2\")\n",
1177            "(name \"Rate\" \"0.2\")\n",
1178            "(table \"Sales\" \"Q1 Data\" \"A1:B2\")\n",
1179            "(cell \"Model\" \"A1\" \"Rate applies\")\n",
1180            "(cell \"Model\" \"B1\" 1200)\n",
1181            "(cell \"Model\" \"C1\" 2400)\n",
1182            "(formula \"Model\" \"C1\" \"=B1*2\")\n",
1183            "(refers \"Model!C1\" \"Model!B1\")\n",
1184            "(cell \"Model\" \"D1\" (error \"#DIV/0!\"))\n",
1185            "(formula \"Model\" \"D1\" \"=1/0\")\n",
1186            "(cell \"Model\" \"E1\" (error \"#N/A\"))\n",
1187            "(formula \"Model\" \"E1\" \"=NA()\")\n",
1188            "(cell \"Model\" \"A2\" true)\n",
1189            "(cell \"Model\" \"C2\" (date \"2025-09-30\"))\n",
1190            "(cell \"Model\" \"D2\" (date \"PT12H30M00S\"))\n",
1191            "(cell \"Model\" \"A3\" true)\n",
1192            "(cell \"Model\" \"C3\" (date \"2025-09-30\"))\n",
1193            "(cell \"Model\" \"D3\" (date \"PT12H30M00S\"))\n",
1194            "(cell \"Model\" \"A4\" 2400)\n",
1195            "(formula \"Model\" \"A4\" \"=B1:B2*2\")\n",
1196            "(refers \"Model!A4\" \"Model!B1:B2\")\n",
1197            // Two paragraphs are two lines; the printer writes the break as
1198            // the reference's WriteDatum does, as itself.
1199            "(cell \"Model\" \"B4\" \"a  b\nc\")\n",
1200            "(cell \"Model\" \"C4\" \"a  b\nc\")\n",
1201            "(cell \"Model\" \"D4\" (error \"#REF!\"))\n",
1202            "(formula \"Model\" \"D4\" \"=[Rates.xlsx]Sheet1!A1\")\n",
1203            "(refers \"Model!D4\" \"[Rates.xlsx]Sheet1!A1\")\n",
1204            "(cell \"Q1 Data\" \"B2\" 0.2)\n",
1205        );
1206        assert_eq!(got, want);
1207        let doc = Document::open(&bytes, "t.ods").unwrap();
1208        let summary = doc.summary();
1209        assert_eq!(
1210            (summary.names, summary.tables, summary.external_books),
1211            (3, 1, 1)
1212        );
1213        let mut distinct = HashSet::new();
1214        let stats = doc
1215            .walk_sheet_with(0, &mut super::super::Discard, &mut distinct)
1216            .unwrap();
1217        assert_eq!(
1218            (
1219                stats.cells,
1220                stats.formulas,
1221                stats.array_anchors,
1222                stats.rows,
1223                stats.columns
1224            ),
1225            (15, 5, 1, 4, 5)
1226        );
1227        assert_eq!((stats.distinct_r1c1, stats.unreadable), (5, 0));
1228    }
1229
1230    #[test]
1231    fn what_is_not_a_spreadsheet_is_refused_by_name() {
1232        let text_doc = ods(
1233            "<office:document-content/>",
1234            "application/vnd.oasis.opendocument.text",
1235        );
1236        assert!(is_opendocument(&text_doc));
1237        let r = open(&text_doc, "letter.odt").unwrap_err();
1238        assert_eq!(r.id, "reflect-not-a-workbook");
1239        assert!(
1240            r.text.contains("OpenDocument text file, not a spreadsheet"),
1241            "{}",
1242            r.text
1243        );
1244        let no_content =
1245            zip::write_stored(&[("mimetype".to_string(), MIMETYPE.as_bytes().to_vec())]).unwrap();
1246        let r = open(&no_content, "x.ods").unwrap_err();
1247        assert!(r.text.contains("no content.xml"), "{}", r.text);
1248        let doctype = ods("<!DOCTYPE x><office:document-content/>", MIMETYPE);
1249        let r = open(&doctype, "d.ods").unwrap_err();
1250        assert_eq!(r.id, "reflect-xml-refused");
1251        // A plain package is not an OpenDocument file.
1252        let pkg =
1253            zip::write_stored(&[("xl/workbook.xml".to_string(), b"<workbook/>".to_vec())]).unwrap();
1254        assert!(!is_opendocument(&pkg));
1255        assert!(!is_opendocument(b"nope"));
1256    }
1257}