Skip to main content

frazaro_core/reflect/
ooxml.rs

1//! OOXML in (PORT.8, slice 8a): a workbook's package read as relations.
2//!
3//! New ground, said plainly: the reference opens a workbook in Excel and
4//! reads cells through the object model; the package is never seen. What is
5//! read here, through `sheet::zip`, `sheet::inflate` and the cursor: the
6//! workbook part (its sheet list with each sheet's state, its defined names,
7//! its external references), the workbook's relationships (which part each
8//! sheet is), the shared-string table (held whole, since any cell may name
9//! any string), each sheet's relationships and the Table parts they name,
10//! each external link's relationships (the linked file's name), and, one at
11//! a time as [`Package::walk_sheet`] is called, the sheet parts: `sheetData`,
12//! its rows, each cell's address, type, formula and value. Styles, the
13//! theme, the calculation chain, comments, drawings and the properties are
14//! never decoded.
15//!
16//! What a cell gives: a `cell` row when the file holds a value (a constant,
17//! or a formula's cached value), a `formula` row when it holds a formula
18//! (its text with `=` in front and the file's `_xlfn.` prefixes dropped,
19//! which is what `Range.Formula` returns, and a link's `[1]` resolved to
20//! the linked file's name, `[Rates.xlsx]`, as the formula bar shows it; a
21//! shared formula's children get the first cell's text with its references
22//! moved by the cell's distance, as Excel shows them; an array formula is
23//! its anchor's text, and the cells it spills into are values alone), then
24//! from 8b its `refers` rows, one per distinct thing the formula refers to
25//! as `crate::refers` reads it, sorted by the target as printed; nothing
26//! for a formatted cell with no value. Excel's own `_xlfn.` placeholder
27//! names, which no one defined, print in no `name` row and are counted
28//! apart.
29
30use std::borrow::Cow;
31use std::collections::HashSet;
32
33use super::cursor::{attr, element_text, skip_element, Cursor, Event};
34use super::{
35    emit_formula, NameInfo, Row, SheetInfo, SheetStats, Sink, Source, Summary, TableInfo, Value,
36    Visibility,
37};
38use crate::intrinsics::fold;
39use crate::messages::{raise, Refusal};
40use crate::refers;
41use crate::sheet::merge::part_text_raw;
42use crate::sheet::zip::{self, Entry};
43use crate::sheet::{cell_ref, parse_a1_range, xlfn};
44
45/// The most distinct R1C1 formulas a count keeps, per sheet and per
46/// workbook; past it the count stands still (AXM.1's own cap).
47const WORKBOOK: &str = "xl/workbook.xml";
48const WORKBOOK_RELS: &str = "xl/_rels/workbook.xml.rels";
49const SHARED_STRINGS: &str = "xl/sharedStrings.xml";
50
51struct Rel {
52    id: String,
53    kind: String,
54    target: String,
55}
56
57/// A package opened far enough to walk: its sheet list, names, Tables,
58/// external books and shared strings. The sheet parts are read one at a
59/// time by [`Package::walk_sheet`] and dropped.
60pub struct Package<'a> {
61    label: String,
62    bytes: &'a [u8],
63    entries: Vec<Entry>,
64    sheets: Vec<SheetInfo>,
65    names: Vec<NameInfo>,
66    tables: Vec<TableInfo>,
67    external_books: Vec<String>,
68    strings: Vec<String>,
69    string_bytes: usize,
70}
71
72impl std::fmt::Debug for Package<'_> {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        f.debug_struct("Package")
75            .field("label", &self.label)
76            .field("bytes", &self.bytes.len())
77            .field("sheets", &self.sheets)
78            .field("names", &self.names)
79            .field("tables", &self.tables)
80            .field("external_books", &self.external_books)
81            .field("strings", &self.strings.len())
82            .finish()
83    }
84}
85
86pub(crate) fn not_a_workbook(label: &str, why: &str) -> Refusal {
87    raise("reflect-not-a-workbook", &[("path", label), ("why", why)])
88}
89
90pub(crate) fn xml_refused(label: &str, part: &str, why: &str) -> Refusal {
91    raise(
92        "reflect-xml-refused",
93        &[("path", label), ("part", part), ("why", why)],
94    )
95}
96
97fn unsupported(label: &str, part: &str, why: &str) -> Refusal {
98    raise(
99        "reflect-unsupported",
100        &[("path", label), ("part", part), ("why", why)],
101    )
102}
103
104/// The next event of a part, the cursor's refusal given the part's name.
105pub(crate) fn step<'t>(
106    cursor: &mut Cursor<'t>,
107    label: &str,
108    part: &str,
109) -> Result<Option<Event<'t>>, Refusal> {
110    cursor
111        .next_event()
112        .map_err(|why| xml_refused(label, part, why))
113}
114
115/// The text of the element just opened, under the part's refusal.
116fn inner_text(
117    cursor: &mut Cursor,
118    skip: &[&str],
119    excel_escapes: bool,
120    label: &str,
121    part: &str,
122) -> Result<String, Refusal> {
123    element_text(cursor, skip, excel_escapes).map_err(|why| xml_refused(label, part, why))
124}
125
126/// A relationship's target resolved against the folder of the part that
127/// names it: `worksheets/sheet1.xml` from `xl/` is `xl/worksheets/sheet1.xml`,
128/// `../tables/table1.xml` from `xl/worksheets/` is `xl/tables/table1.xml`,
129/// and a target that begins with `/` is from the package root.
130fn resolve(base_dir: &str, target: &str) -> String {
131    let joined = match target.strip_prefix('/') {
132        Some(absolute) => absolute.to_string(),
133        None => format!("{base_dir}{target}"),
134    };
135    let mut parts: Vec<&str> = Vec::new();
136    for segment in joined.split('/') {
137        match segment {
138            "" | "." => {}
139            ".." => {
140                parts.pop();
141            }
142            s => parts.push(s),
143        }
144    }
145    parts.join("/")
146}
147
148/// The folder of a part with its slash: `xl/worksheets/` for
149/// `xl/worksheets/sheet1.xml`.
150fn dir_of(part: &str) -> &str {
151    match part.rfind('/') {
152        Some(i) => &part[..=i],
153        None => "",
154    }
155}
156
157/// The relationships part beside a part: `xl/_rels/workbook.xml.rels`.
158fn rels_of(part: &str) -> String {
159    let dir = dir_of(part);
160    format!("{dir}_rels/{}.rels", &part[dir.len()..])
161}
162
163/// The last segment of a path or URL, percent-decoded: `Rates Q1.xlsx` for
164/// `file:///C:/Models/Rates%20Q1.xlsx`.
165fn file_name_of(target: &str) -> String {
166    let last = target.rsplit(['/', '\\']).next().unwrap_or(target);
167    let bytes = last.as_bytes();
168    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
169    let mut i = 0;
170    while i < bytes.len() {
171        if bytes[i] == b'%' && i + 2 < bytes.len() {
172            if let Ok(v) = u8::from_str_radix(&last[i + 1..i + 3], 16) {
173                out.push(v);
174                i += 3;
175                continue;
176            }
177        }
178        out.push(bytes[i]);
179        i += 1;
180    }
181    String::from_utf8(out).unwrap_or_else(|_| last.to_string())
182}
183
184fn read_rels(text: &str, label: &str, part: &str) -> Result<Vec<Rel>, Refusal> {
185    let mut cursor = Cursor::new(text);
186    let mut out = Vec::new();
187    while let Some(event) = step(&mut cursor, label, part)? {
188        if let Event::Start {
189            name: "Relationship",
190            attrs,
191            ..
192        } = event
193        {
194            out.push(Rel {
195                id: attr(attrs, "Id").unwrap_or_default().into_owned(),
196                kind: attr(attrs, "Type").unwrap_or_default().into_owned(),
197                target: attr(attrs, "Target").unwrap_or_default().into_owned(),
198            });
199        }
200    }
201    Ok(out)
202}
203
204fn read_strings(text: &str, label: &str, part: &str) -> Result<Vec<String>, Refusal> {
205    let mut cursor = Cursor::new(text);
206    let mut out = Vec::new();
207    while let Some(event) = step(&mut cursor, label, part)? {
208        if let Event::Start {
209            name: "si", empty, ..
210        } = event
211        {
212            if empty {
213                out.push(String::new());
214            } else {
215                out.push(inner_text(&mut cursor, &["rPh"], true, label, part)?);
216            }
217        }
218    }
219    Ok(out)
220}
221
222fn read_table(
223    text: &str,
224    label: &str,
225    part: &str,
226    sheet: usize,
227) -> Result<Option<TableInfo>, Refusal> {
228    let mut cursor = Cursor::new(text);
229    let mut info: Option<TableInfo> = None;
230    while let Some(event) = step(&mut cursor, label, part)? {
231        match event {
232            Event::Start {
233                name: "table",
234                attrs,
235                ..
236            } => {
237                let name = attr(attrs, "displayName")
238                    .or_else(|| attr(attrs, "name"))
239                    .unwrap_or_default()
240                    .into_owned();
241                info = Some(TableInfo {
242                    name,
243                    sheet,
244                    range: attr(attrs, "ref").unwrap_or_default().into_owned(),
245                    columns: Vec::new(),
246                });
247            }
248            Event::Start {
249                name: "tableColumn",
250                attrs,
251                ..
252            } => {
253                if let Some(t) = info.as_mut() {
254                    t.columns
255                        .push(attr(attrs, "name").unwrap_or_default().into_owned());
256                }
257            }
258            _ => {}
259        }
260    }
261    Ok(info)
262}
263
264impl<'a> Package<'a> {
265    /// The package read far enough to walk; `label` is what a refusal calls
266    /// the file.
267    pub fn open(bytes: &'a [u8], label: &str) -> Result<Package<'a>, Refusal> {
268        let entries = zip::entries(bytes).ok_or_else(|| {
269            not_a_workbook(
270                label,
271                zip::why_not_an_archive(bytes)
272                    .unwrap_or("it is not a zip archive this reader knows"),
273            )
274        })?;
275        let has = |name: &str| entries.iter().any(|e| e.name == name);
276        if !has(WORKBOOK) {
277            return Err(not_a_workbook(
278                label,
279                "it has no xl/workbook.xml part, so it is not a spreadsheet package",
280            ));
281        }
282        let text = |name: &str| -> Result<Option<String>, Refusal> {
283            part_text_raw(bytes, &entries, name).map_err(|why| not_a_workbook(label, &why))
284        };
285        let workbook = text(WORKBOOK)?.unwrap_or_default();
286        let rels_text = text(WORKBOOK_RELS)?
287            .ok_or_else(|| not_a_workbook(label, "it has no xl/_rels/workbook.xml.rels part"))?;
288        let rels = read_rels(&rels_text, label, WORKBOOK_RELS)?;
289        let target_of = |rid: &str| {
290            rels.iter()
291                .find(|r| r.id == rid)
292                .map(|r| resolve("xl/", &r.target))
293        };
294
295        // The workbook part: the sheets, the names, the external references.
296        let mut sheets: Vec<SheetInfo> = Vec::new();
297        let mut names: Vec<NameInfo> = Vec::new();
298        let mut external_rids: Vec<String> = Vec::new();
299        let mut cursor = Cursor::new(&workbook);
300        while let Some(event) = step(&mut cursor, label, WORKBOOK)? {
301            match event {
302                Event::Start {
303                    name: "sheet",
304                    attrs,
305                    ..
306                } => {
307                    let sheet_name = attr(attrs, "name").unwrap_or_default().into_owned();
308                    let visibility = match attr(attrs, "state").as_deref() {
309                        Some("hidden") => Visibility::Hidden,
310                        Some("veryHidden") => Visibility::VeryHidden,
311                        _ => Visibility::Visible,
312                    };
313                    let rid = attr(attrs, "id").unwrap_or_default();
314                    let part = target_of(&rid).ok_or_else(|| {
315                        unsupported(
316                            label,
317                            WORKBOOK,
318                            &format!(
319                                "sheet {sheet_name} names a relationship the workbook does not have"
320                            ),
321                        )
322                    })?;
323                    sheets.push(SheetInfo {
324                        name: sheet_name,
325                        visibility,
326                        part,
327                    });
328                }
329                Event::Start {
330                    name: "definedName",
331                    attrs,
332                    empty,
333                } => {
334                    let name = attr(attrs, "name").unwrap_or_default().into_owned();
335                    let sheet = attr(attrs, "localSheetId").and_then(|v| v.parse::<usize>().ok());
336                    let hidden =
337                        matches!(attr(attrs, "hidden").as_deref(), Some("1") | Some("true"));
338                    let refers_to = if empty {
339                        String::new()
340                    } else {
341                        inner_text(&mut cursor, &[], false, label, WORKBOOK)?
342                    };
343                    let placeholder = fold(&name).starts_with("_xlfn.");
344                    names.push(NameInfo {
345                        name,
346                        refers_to,
347                        sheet,
348                        hidden,
349                        placeholder,
350                    });
351                }
352                Event::Start {
353                    name: "externalReference",
354                    attrs,
355                    ..
356                } => {
357                    if let Some(rid) = attr(attrs, "id") {
358                        external_rids.push(rid.into_owned());
359                    }
360                }
361                _ => {}
362            }
363        }
364        if sheets.is_empty() {
365            return Err(unsupported(label, WORKBOOK, "it lists no sheet"));
366        }
367
368        // The shared strings, held whole.
369        let strings_part = rels
370            .iter()
371            .find(|r| r.kind.ends_with("/sharedStrings"))
372            .map(|r| resolve("xl/", &r.target))
373            .unwrap_or_else(|| SHARED_STRINGS.to_string());
374        let (strings, string_bytes) = match text(&strings_part)? {
375            None => (Vec::new(), 0),
376            Some(t) => (read_strings(&t, label, &strings_part)?, t.len()),
377        };
378
379        // The Tables, through each sheet's relationships.
380        let mut tables: Vec<TableInfo> = Vec::new();
381        for (i, sheet) in sheets.iter().enumerate() {
382            let rels_part = rels_of(&sheet.part);
383            let Some(rels_text) = text(&rels_part)? else {
384                continue;
385            };
386            for rel in read_rels(&rels_text, label, &rels_part)? {
387                if !rel.kind.ends_with("/table") {
388                    continue;
389                }
390                let table_part = resolve(dir_of(&sheet.part), &rel.target);
391                let Some(table_text) = text(&table_part)? else {
392                    continue;
393                };
394                if let Some(table) = read_table(&table_text, label, &table_part, i)? {
395                    tables.push(table);
396                }
397            }
398        }
399
400        // The external books, each by the name of its file.
401        let mut external_books: Vec<String> = Vec::new();
402        for rid in external_rids {
403            let Some(link_part) = target_of(&rid) else {
404                continue;
405            };
406            let link_rels = rels_of(&link_part);
407            let name = match text(&link_rels)? {
408                Some(rels_text) => read_rels(&rels_text, label, &link_rels)?
409                    .into_iter()
410                    .find(|r| r.kind.ends_with("/externalLinkPath"))
411                    .map(|r| file_name_of(&r.target))
412                    .unwrap_or_default(),
413                None => String::new(),
414            };
415            external_books.push(name);
416        }
417
418        Ok(Package {
419            label: label.to_string(),
420            bytes,
421            entries,
422            sheets,
423            names,
424            tables,
425            external_books,
426            strings,
427            string_bytes,
428        })
429    }
430}
431
432impl<'a> Source for Package<'a> {
433    fn label(&self) -> &str {
434        &self.label
435    }
436
437    /// The sheets in tab order.
438    fn sheets(&self) -> &[SheetInfo] {
439        &self.sheets
440    }
441
442    /// Every defined name, placeholders included, in the file's order.
443    fn names(&self) -> &[NameInfo] {
444        &self.names
445    }
446
447    /// Every Table, in the order the sheets' relationships name them.
448    fn tables(&self) -> &[TableInfo] {
449        &self.tables
450    }
451
452    /// The linked workbooks' file names, in the order `[1]`, `[2]`, ... of a
453    /// formula's text; an empty name for a link of another kind.
454    fn external_books(&self) -> &[String] {
455        &self.external_books
456    }
457
458    fn summary(&self) -> Summary {
459        let placeholders = self.names.iter().filter(|n| n.placeholder).count();
460        Summary {
461            names: self.names.len() - placeholders,
462            placeholders,
463            tables: self.tables.len(),
464            external_books: self.external_books.len(),
465            strings: self.strings.len(),
466            string_bytes: self.string_bytes,
467        }
468    }
469
470    /// One sheet's cells in document order, to the sink: a `cell` row for a
471    /// value the file holds, a `formula` row for a formula, then its
472    /// `refers` rows. The sheet's part is read here and dropped on return;
473    /// what the walk counted comes back, with the workbook-wide set of
474    /// distinct R1C1 formulas carried across sheets for `--counts`.
475    fn walk_sheet_with(
476        &self,
477        index: usize,
478        sink: &mut dyn Sink,
479        book_distinct: &mut HashSet<String>,
480    ) -> Result<SheetStats, Refusal> {
481        let info = &self.sheets[index];
482        let mut sheet_distinct: HashSet<String> = HashSet::new();
483        let part = info.part.as_str();
484        let label = self.label.as_str();
485        let text = part_text_raw(self.bytes, &self.entries, part)
486            .map_err(|why| not_a_workbook(label, &why))?
487            .ok_or_else(|| {
488                unsupported(label, part, "the sheet's part is missing from the package")
489            })?;
490        let mut stats = SheetStats {
491            part_bytes: text.len(),
492            ..SheetStats::default()
493        };
494        let mut cursor = Cursor::new(&text);
495        // Shared formulas by index: the first cell's text and position.
496        let mut shared: Vec<Option<(String, u32, u32)>> = Vec::new();
497        let mut in_data = false;
498        let mut row_no: u32 = 0;
499        let mut col_no: u32 = 0;
500        while let Some(event) = step(&mut cursor, label, part)? {
501            match event {
502                Event::Start {
503                    name: "sheetData",
504                    empty,
505                    ..
506                } => {
507                    if empty {
508                        break;
509                    }
510                    in_data = true;
511                }
512                Event::End { name: "sheetData" } => break,
513                Event::Start {
514                    name: "row", attrs, ..
515                } if in_data => {
516                    row_no = attr(attrs, "r")
517                        .and_then(|v| v.parse::<u32>().ok())
518                        .unwrap_or(row_no + 1);
519                    col_no = 0;
520                }
521                Event::Start {
522                    name: "c",
523                    attrs,
524                    empty,
525                } if in_data => {
526                    let (row, col) = match attr(attrs, "r").and_then(|r| parse_a1_range(&r)) {
527                        Some(a) => (a.top, a.left),
528                        None => (row_no, col_no + 1),
529                    };
530                    row_no = row;
531                    col_no = col;
532                    let kind: Option<Cow<str>> = attr(attrs, "t");
533                    let mut formula_part: Option<(Option<String>, Option<String>, String)> = None;
534                    let mut value_text: Option<String> = None;
535                    let mut inline: Option<String> = None;
536                    if !empty {
537                        loop {
538                            match step(&mut cursor, label, part)? {
539                                None => {
540                                    return Err(xml_refused(label, part, "a cell never closes"))
541                                }
542                                Some(Event::Start {
543                                    name: "f",
544                                    attrs: f_attrs,
545                                    empty: f_empty,
546                                }) => {
547                                    let t = attr(f_attrs, "t").map(Cow::into_owned);
548                                    let si = attr(f_attrs, "si").map(Cow::into_owned);
549                                    let f_text = if f_empty {
550                                        String::new()
551                                    } else {
552                                        inner_text(&mut cursor, &[], false, label, part)?
553                                    };
554                                    formula_part = Some((t, si, f_text));
555                                }
556                                Some(Event::Start {
557                                    name: "v",
558                                    empty: v_empty,
559                                    ..
560                                }) => {
561                                    value_text = Some(if v_empty {
562                                        String::new()
563                                    } else {
564                                        inner_text(&mut cursor, &[], false, label, part)?
565                                    });
566                                }
567                                Some(Event::Start {
568                                    name: "is",
569                                    empty: is_empty,
570                                    ..
571                                }) => {
572                                    inline = Some(if is_empty {
573                                        String::new()
574                                    } else {
575                                        inner_text(&mut cursor, &["rPh"], true, label, part)?
576                                    });
577                                }
578                                Some(Event::Start { empty: false, .. }) => {
579                                    skip_element(&mut cursor)
580                                        .map_err(|why| xml_refused(label, part, why))?;
581                                }
582                                Some(Event::End { name: "c" }) => break,
583                                _ => {}
584                            }
585                        }
586                    }
587                    let addr = cell_ref(row, col);
588                    let formula = match formula_part {
589                        None => None,
590                        Some((t, si, f_text)) => {
591                            match t.as_deref() {
592                                Some("shared") => {
593                                    let i: usize = si
594                                    .as_deref()
595                                    .and_then(|s| s.trim().parse().ok())
596                                    .ok_or_else(|| {
597                                        unsupported(
598                                            label,
599                                            part,
600                                            &format!("cell {addr} holds a shared formula with no index"),
601                                        )
602                                    })?;
603                                    if f_text.is_empty() {
604                                        let (master, m_row, m_col) =
605                                        shared.get(i).and_then(|m| m.as_ref()).ok_or_else(|| {
606                                            unsupported(
607                                                label,
608                                                part,
609                                                &format!("cell {addr} continues a shared formula whose first cell has not been read"),
610                                            )
611                                        })?;
612                                        Some(refers::shift_a1_references(
613                                            master,
614                                            i64::from(row) - i64::from(*m_row),
615                                            i64::from(col) - i64::from(*m_col),
616                                        ))
617                                    } else {
618                                        if shared.len() <= i {
619                                            shared.resize(i + 1, None);
620                                        }
621                                        shared[i] = Some((f_text.clone(), row, col));
622                                        Some(f_text)
623                                    }
624                                }
625                                Some("array") => {
626                                    if f_text.is_empty() {
627                                        None
628                                    } else {
629                                        stats.array_anchors += 1;
630                                        Some(f_text)
631                                    }
632                                }
633                                Some("dataTable") => {
634                                    return Err(unsupported(
635                                        label,
636                                        part,
637                                        &format!("cell {addr} holds a data-table formula"),
638                                    ))
639                                }
640                                _ => {
641                                    if f_text.is_empty() {
642                                        None
643                                    } else {
644                                        Some(f_text)
645                                    }
646                                }
647                            }
648                        }
649                    };
650                    let value_text = value_text.filter(|s| !s.is_empty());
651                    let value = match kind.as_deref() {
652                        Some("s") => match value_text {
653                            Some(s) => {
654                                let i: usize = s.trim().parse().map_err(|_| {
655                                    unsupported(
656                                        label,
657                                        part,
658                                        &format!("cell {addr} names a shared string by {s}, which is not an index"),
659                                    )
660                                })?;
661                                let text = self.strings.get(i).cloned().ok_or_else(|| {
662                                    unsupported(
663                                        label,
664                                        part,
665                                        &format!("cell {addr} names shared string {i}, past the end of the table"),
666                                    )
667                                })?;
668                                Some(Value::Text(text))
669                            }
670                            None => None,
671                        },
672                        Some("str") => value_text.map(Value::Text),
673                        Some("b") => value_text.map(|s| {
674                            let s = s.trim();
675                            Value::Bool(s == "1" || s.eq_ignore_ascii_case("true"))
676                        }),
677                        Some("e") => value_text.map(|s| Value::Error(s.trim().to_string())),
678                        Some("d") => value_text.map(|s| Value::Date(s.trim().to_string())),
679                        Some("inlineStr") => inline.map(Value::Text),
680                        _ => value_text.map(|s| Value::Number(s.trim().to_string())),
681                    };
682                    if value.is_none() && formula.is_none() {
683                        continue;
684                    }
685                    stats.cells += 1;
686                    stats.rows = stats.rows.max(row);
687                    stats.columns = stats.columns.max(col);
688                    if let Some(value) = &value {
689                        sink.row(&Row::Cell {
690                            sheet: &info.name,
691                            addr: &addr,
692                            value,
693                        });
694                    }
695                    if let Some(f_text) = formula {
696                        let text = format!("={}", xlfn::strip_future_prefixes(&f_text));
697                        let text = refers::resolve_books(&text, &self.external_books);
698                        emit_formula(
699                            sink,
700                            &info.name,
701                            &addr,
702                            &text,
703                            row,
704                            col,
705                            &mut stats,
706                            &mut sheet_distinct,
707                            book_distinct,
708                        );
709                    }
710                }
711                _ => {}
712            }
713        }
714        stats.distinct_r1c1 = sheet_distinct.len() as u64;
715        Ok(stats)
716    }
717}
718
719#[cfg(test)]
720mod tests {
721    use super::*;
722    use crate::reflect::reflect_text;
723
724    const NS: &str = "xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\"";
725    const REL_SHEET: &str =
726        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet";
727    const REL_SST: &str =
728        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings";
729    const REL_TABLE: &str =
730        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/table";
731
732    /// A package of one sheet, with a string table when given.
733    fn one_sheet(sheet_data: &str, sst: Option<&str>) -> Vec<u8> {
734        let mut parts: Vec<(String, Vec<u8>)> = vec![
735            ("xl/workbook.xml".to_string(), format!("<workbook {NS}><sheets><sheet name=\"Model\" sheetId=\"1\" r:id=\"rId1\"/></sheets></workbook>").into_bytes()),
736            ("xl/_rels/workbook.xml.rels".to_string(), format!("<Relationships><Relationship Id=\"rId1\" Type=\"{REL_SHEET}\" Target=\"worksheets/sheet1.xml\"/><Relationship Id=\"rId2\" Type=\"{REL_SST}\" Target=\"sharedStrings.xml\"/></Relationships>").into_bytes()),
737            ("xl/worksheets/sheet1.xml".to_string(), format!("<worksheet {NS}><dimension ref=\"A1\"/><sheetData>{sheet_data}</sheetData><pageMargins left=\"0.7\"/></worksheet>").into_bytes()),
738        ];
739        if let Some(s) = sst {
740            parts.push((
741                "xl/sharedStrings.xml".to_string(),
742                format!("<sst {NS}>{s}</sst>").into_bytes(),
743            ));
744        }
745        zip::write_stored(&parts).unwrap()
746    }
747
748    #[test]
749    fn each_kind_of_cell_reads() {
750        let sst = "<si><t>Revenue</t></si><si><r><t>Rate </t></r><r><rPr><b/></rPr><t>applies</t></r><rPh sb=\"0\" eb=\"1\"><t>x</t></rPh></si>";
751        let data = concat!(
752            "<row r=\"1\"><c r=\"A1\" t=\"s\"><v>0</v></c><c r=\"B1\"><v>1200</v></c><c r=\"C1\" s=\"3\"/><c r=\"D1\" t=\"inlineStr\"><is><t>in &amp; line</t></is></c></row>",
753            "<row r=\"2\"><c r=\"A2\" t=\"s\"><v>1</v></c><c r=\"B2\" t=\"b\"><v>1</v></c><c r=\"C2\" t=\"e\"><f>1/0</f><v>#DIV/0!</v></c><c r=\"D2\" t=\"d\"><v>2026-10-03</v></c></row>",
754            "<row r=\"3\"><c r=\"A3\" t=\"str\"><f>A1&amp;\"!\"</f><v>Revenue!</v></c><c r=\"B3\"><f>B1*2</f><v>2400</v></c><c r=\"C3\"><f t=\"shared\" ref=\"C3:C4\" si=\"0\">B3+1</f><v>2401</v></c></row>",
755            "<row r=\"4\"><c r=\"C4\"><f t=\"shared\" si=\"0\"/><v>1</v></c><c r=\"D4\" cm=\"1\"><f t=\"array\" ref=\"D4:D5\">_xlfn.SEQUENCE(2)</f><v>1</v></c></row>",
756            "<row r=\"5\"><c r=\"D5\"><v>2</v></c><c><v>7</v></c><c t=\"s\"><v>0</v></c></row>",
757            "<row><c r=\"A6\"><f></f><v>0.30000000000000004</v></c><c r=\"B6\"><v></v></c></row>",
758        );
759        let bytes = one_sheet(data, Some(sst));
760        let text = reflect_text(&bytes, "t.xlsx").unwrap();
761        assert_eq!(
762            text,
763            concat!(
764                "(sheet \"Model\" visible)\n",
765                "(cell \"Model\" \"A1\" \"Revenue\")\n",
766                "(cell \"Model\" \"B1\" 1200)\n",
767                "(cell \"Model\" \"D1\" \"in & line\")\n",
768                "(cell \"Model\" \"A2\" \"Rate applies\")\n",
769                "(cell \"Model\" \"B2\" true)\n",
770                "(cell \"Model\" \"C2\" (error \"#DIV/0!\"))\n",
771                "(formula \"Model\" \"C2\" \"=1/0\")\n",
772                "(cell \"Model\" \"D2\" (date \"2026-10-03\"))\n",
773                "(cell \"Model\" \"A3\" \"Revenue!\")\n",
774                "(formula \"Model\" \"A3\" \"=A1&\\\"!\\\"\")\n",
775                "(refers \"Model!A3\" \"Model!A1\")\n",
776                "(cell \"Model\" \"B3\" 2400)\n",
777                "(formula \"Model\" \"B3\" \"=B1*2\")\n",
778                "(refers \"Model!B3\" \"Model!B1\")\n",
779                "(cell \"Model\" \"C3\" 2401)\n",
780                "(formula \"Model\" \"C3\" \"=B3+1\")\n",
781                "(refers \"Model!C3\" \"Model!B3\")\n",
782                "(cell \"Model\" \"C4\" 1)\n",
783                "(formula \"Model\" \"C4\" \"=B4+1\")\n",
784                "(refers \"Model!C4\" \"Model!B4\")\n",
785                "(cell \"Model\" \"D4\" 1)\n",
786                "(formula \"Model\" \"D4\" \"=SEQUENCE(2)\")\n",
787                "(cell \"Model\" \"D5\" 2)\n",
788                "(cell \"Model\" \"E5\" 7)\n",
789                "(cell \"Model\" \"F5\" \"Revenue\")\n",
790                "(cell \"Model\" \"A6\" 0.30000000000000004)\n",
791            )
792        );
793        let package = Package::open(&bytes, "t.xlsx").unwrap();
794        let stats = package.walk_sheet(0, &mut super::super::Discard).unwrap();
795        assert_eq!(stats.cells, 16);
796        assert_eq!(stats.formulas, 6);
797        assert_eq!(stats.array_anchors, 1);
798        // =1/0, =RC[-2]&"!", =RC[-1]*2, =RC[-1]+1 (C3 and C4 as one), =SEQUENCE(2).
799        assert_eq!((stats.distinct_r1c1, stats.unreadable), (5, 0));
800        assert_eq!((stats.rows, stats.columns), (6, 6));
801        assert!(stats.part_bytes > 500);
802        let summary = package.summary();
803        assert_eq!((summary.strings, summary.names, summary.tables), (2, 0, 0));
804    }
805
806    #[test]
807    fn the_header_rows_come_in_the_fixed_order() {
808        let parts: Vec<(String, Vec<u8>)> = vec![
809            ("xl/workbook.xml".to_string(), format!("<workbook {NS}><sheets><sheet name=\"Model\" sheetId=\"1\" r:id=\"rId1\"/><sheet name=\"Q1 Data\" sheetId=\"3\" state=\"hidden\" r:id=\"rId2\"/><sheet name=\"Secret\" sheetId=\"4\" state=\"veryHidden\" r:id=\"rId3\"/></sheets><externalReferences><externalReference r:id=\"rId9\"/></externalReferences><definedNames><definedName name=\"rate\">0.2</definedName><definedName name=\"_xlfn.SINGLE\" hidden=\"1\">#NAME?</definedName><definedName name=\"Local\" localSheetId=\"1\">'Q1 Data'!$B$2</definedName><definedName name=\"Broken\" hidden=\"1\">Model!#REF!</definedName><definedName name=\"Alpha\">Model!$A$1</definedName></definedNames></workbook>").into_bytes()),
810            ("xl/_rels/workbook.xml.rels".to_string(), format!("<Relationships><Relationship Id=\"rId1\" Type=\"{REL_SHEET}\" Target=\"worksheets/sheet1.xml\"/><Relationship Id=\"rId2\" Type=\"{REL_SHEET}\" Target=\"/xl/worksheets/sheet2.xml\"/><Relationship Id=\"rId3\" Type=\"{REL_SHEET}\" Target=\"worksheets/sheet3.xml\"/><Relationship Id=\"rId9\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/externalLink\" Target=\"externalLinks/externalLink1.xml\"/></Relationships>").into_bytes()),
811            ("xl/worksheets/sheet1.xml".to_string(), format!("<worksheet {NS}><sheetData><row r=\"1\"><c r=\"A1\"><v>1</v></c></row></sheetData><tableParts count=\"2\"><tablePart r:id=\"rId1\"/><tablePart r:id=\"rId2\"/></tableParts></worksheet>").into_bytes()),
812            ("xl/worksheets/_rels/sheet1.xml.rels".to_string(), format!("<Relationships><Relationship Id=\"rId1\" Type=\"{REL_TABLE}\" Target=\"../tables/table1.xml\"/><Relationship Id=\"rId2\" Type=\"{REL_TABLE}\" Target=\"../tables/table2.xml\"/></Relationships>").into_bytes()),
813            ("xl/tables/table1.xml".to_string(), format!("<table {NS} id=\"1\" name=\"Table1\" displayName=\"Sales\" ref=\"A1:B4\"><tableColumns count=\"2\"><tableColumn id=\"1\" name=\"Item\"/><tableColumn id=\"2\" name=\"Amount\"/></tableColumns></table>").into_bytes()),
814            ("xl/tables/table2.xml".to_string(), format!("<table {NS} id=\"2\" name=\"Costs\" displayName=\"Costs\" ref=\"D1:D9\"><tableColumns count=\"1\"><tableColumn id=\"1\" name=\"Cost\"/></tableColumns></table>").into_bytes()),
815            ("xl/worksheets/sheet2.xml".to_string(), format!("<worksheet {NS}><sheetData/></worksheet>").into_bytes()),
816            ("xl/worksheets/sheet3.xml".to_string(), format!("<worksheet {NS}><sheetData><row r=\"2\"><c r=\"B2\"><v>10</v></c></row></sheetData></worksheet>").into_bytes()),
817            ("xl/externalLinks/externalLink1.xml".to_string(), format!("<externalLink {NS}><externalBook r:id=\"rId1\"><sheetNames><sheetName val=\"Sheet1\"/></sheetNames></externalBook></externalLink>").into_bytes()),
818            ("xl/externalLinks/_rels/externalLink1.xml.rels".to_string(), "<Relationships><Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/externalLinkPath\" Target=\"file:///C:/Models/Rates%20Q1.xlsx\" TargetMode=\"External\"/></Relationships>".to_string().into_bytes()),
819        ];
820        let bytes = zip::write_stored(&parts).unwrap();
821        let text = reflect_text(&bytes, "t.xlsx").unwrap();
822        assert_eq!(
823            text,
824            concat!(
825                "(sheet \"Model\" visible)\n",
826                "(sheet \"Q1 Data\" hidden)\n",
827                "(sheet \"Secret\" very-hidden)\n",
828                "(name \"'Q1 Data'!Local\" \"'Q1 Data'!$B$2\")\n",
829                "(name \"Alpha\" \"Model!$A$1\")\n",
830                "(name \"Broken\" \"Model!#REF!\")\n",
831                "(name \"rate\" \"0.2\")\n",
832                "(table \"Costs\" \"Model\" \"D1:D9\")\n",
833                "(table \"Sales\" \"Model\" \"A1:B4\")\n",
834                "(cell \"Model\" \"A1\" 1)\n",
835                "(cell \"Secret\" \"B2\" 10)\n",
836            )
837        );
838        let package = Package::open(&bytes, "t.xlsx").unwrap();
839        assert_eq!(package.external_books(), ["Rates Q1.xlsx"]);
840        assert_eq!(package.tables()[0].columns, ["Item", "Amount"]);
841        let summary = package.summary();
842        assert_eq!(
843            (
844                summary.names,
845                summary.placeholders,
846                summary.tables,
847                summary.external_books
848            ),
849            (4, 1, 2, 1)
850        );
851        assert!(package
852            .names()
853            .iter()
854            .any(|n| n.name == "Broken" && n.hidden));
855    }
856
857    #[test]
858    fn what_is_refused_is_named_through_the_catalogue() {
859        let r = Package::open(b"nope", "x.xlsx").unwrap_err();
860        assert_eq!(r.id, "reflect-not-a-workbook");
861        assert!(r.text.starts_with("x.xlsx is not a workbook frazaro reflect can read: it has no end-of-central-directory record"), "{}", r.text);
862        let ole = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1, 0, 0, 0, 0];
863        assert!(Package::open(&ole, "old.xls")
864            .unwrap_err()
865            .text
866            .contains("older .xls"));
867        let no_wb = zip::write_stored(&[("a.txt".to_string(), b"hi".to_vec())]).unwrap();
868        assert!(Package::open(&no_wb, "x.zip")
869            .unwrap_err()
870            .text
871            .contains("no xl/workbook.xml"));
872        // An OpenDocument file opens through its own reader (8e); given to
873        // this one directly, it is a package with no workbook part.
874        let ods = zip::write_stored(&[
875            (
876                "mimetype".to_string(),
877                b"application/vnd.oasis.opendocument.spreadsheet".to_vec(),
878            ),
879            (
880                "content.xml".to_string(),
881                b"<office:document-content/>".to_vec(),
882            ),
883        ])
884        .unwrap();
885        assert!(Package::open(&ods, "x.ods")
886            .unwrap_err()
887            .text
888            .contains("no xl/workbook.xml"));
889        assert!(crate::reflect::odf::is_opendocument(&ods));
890        // A declaration in a part is refused before anything else is read.
891        let doctype = one_sheet(
892            "<!DOCTYPE x><row r=\"1\"><c r=\"A1\"><v>1</v></c></row>",
893            None,
894        );
895        let r = reflect_text(&doctype, "d.xlsx").unwrap_err();
896        assert_eq!(r.id, "reflect-xml-refused");
897        assert!(
898            r.text
899                .contains("xl/worksheets/sheet1.xml, it declares a DOCTYPE"),
900            "{}",
901            r.text
902        );
903        // A shape this version does not read is named with its cell.
904        let table = one_sheet("<row r=\"1\"><c r=\"B2\"><f t=\"dataTable\" ref=\"B2:B3\" dt2D=\"0\" r1=\"A1\"/><v>1</v></c></row>", None);
905        let r = reflect_text(&table, "d.xlsx").unwrap_err();
906        assert_eq!(r.id, "reflect-unsupported");
907        assert!(
908            r.text.contains("cell B2 holds a data-table formula"),
909            "{}",
910            r.text
911        );
912        let orphan = one_sheet(
913            "<row r=\"1\"><c r=\"A1\"><f t=\"shared\" si=\"4\"/><v>1</v></c></row>",
914            None,
915        );
916        assert!(reflect_text(&orphan, "d.xlsx")
917            .unwrap_err()
918            .text
919            .contains("cell A1 continues a shared formula"));
920        let past = one_sheet(
921            "<row r=\"1\"><c r=\"A1\" t=\"s\"><v>3</v></c></row>",
922            Some("<si><t>a</t></si>"),
923        );
924        assert!(reflect_text(&past, "d.xlsx")
925            .unwrap_err()
926            .text
927            .contains("shared string 3, past the end"));
928        // A damaged part is the archive's fault, not a shape's.
929        let mut damaged = one_sheet("<row r=\"1\"><c r=\"A1\"><v>1</v></c></row>", None);
930        let e = zip::entries(&damaged)
931            .unwrap()
932            .into_iter()
933            .find(|e| e.name == "xl/worksheets/sheet1.xml")
934            .unwrap();
935        damaged[e.data_offset + 10] ^= 1;
936        let r = reflect_text(&damaged, "d.xlsx").unwrap_err();
937        assert_eq!(r.id, "reflect-not-a-workbook");
938        assert!(r.text.contains("checksum does not match"), "{}", r.text);
939    }
940
941    /// The treaty's oracle 8 (conformance/README.md, the amendment of
942    /// 2026-10-03 for PORT.8): each fixture's relations are its golden
943    /// whole, line endings aside. The goldens are the core's own output,
944    /// blessed by the owner reading each fixture in Excel; a deliberate
945    /// change regenerates them with the door and raises the check's floors.
946    #[test]
947    fn the_reflect_goldens_are_reproduced() {
948        let rows: [(&str, &[u8], &str); 8] = [
949            // The twin as Excel 365 saved it (the owner's pass, 2026-10-05):
950            // Excel's own ODF dialect, the filler rows repeated a million
951            // times, a sheet renamed, the link dropped, `@` as SINGLE.
952            (
953                "scripts/reflect/opendocument_saved.ods",
954                include_bytes!("../../../scripts/reflect/opendocument_saved.ods"),
955                include_str!("../../../scripts/reflect/opendocument_saved_relations.vla"),
956            ),
957            // The fixture's changed copy (8c) and its OpenDocument twin (8e).
958            (
959                "scripts/reflect/changed.xlsx",
960                include_bytes!("../../../scripts/reflect/changed.xlsx"),
961                include_str!("../../../scripts/reflect/changed_relations.vla"),
962            ),
963            (
964                "scripts/reflect/opendocument.ods",
965                include_bytes!("../../../scripts/reflect/opendocument.ods"),
966                include_str!("../../../scripts/reflect/opendocument_relations.vla"),
967            ),
968            // The first build golden as Excel 365 saved it (the owner's
969            // live pass, 2026-10-04): a cell row for every formula's cached
970            // value, and the stamp's long text as _xlfn._LONGTEXT.
971            (
972                "scripts/reflect/saved.xlsx",
973                include_bytes!("../../../scripts/reflect/saved.xlsx"),
974                include_str!("../../../scripts/reflect/saved_relations.vla"),
975            ),
976            (
977                "scripts/reflect/fixture.xlsx",
978                include_bytes!("../../../scripts/reflect/fixture.xlsx"),
979                include_str!("../../../scripts/reflect/fixture_relations.vla"),
980            ),
981            (
982                "scripts/build/fixture_golden.xlsx",
983                include_bytes!("../../../scripts/build/fixture_golden.xlsx"),
984                include_str!("../../../scripts/reflect/build_fixture_relations.vla"),
985            ),
986            (
987                "scripts/build/into_golden.xlsx",
988                include_bytes!("../../../scripts/build/into_golden.xlsx"),
989                include_str!("../../../scripts/reflect/build_into_relations.vla"),
990            ),
991            (
992                "scripts/build/model.xlsx",
993                include_bytes!("../../../scripts/build/model.xlsx"),
994                include_str!("../../../scripts/reflect/model_relations.vla"),
995            ),
996        ];
997        for (label, bytes, golden) in rows {
998            let got = reflect_text(bytes, label)
999                .unwrap_or_else(|r| panic!("{label} did not read: {}", r.text));
1000            let want = golden.replace("\r\n", "\n");
1001            if got != want {
1002                let line = got
1003                    .lines()
1004                    .zip(want.lines())
1005                    .position(|(a, b)| a != b)
1006                    .map(|i| i + 1)
1007                    .unwrap_or(got.lines().count().min(want.lines().count()) + 1);
1008                panic!("{label}: the relations differ from the golden at line {line}");
1009            }
1010        }
1011    }
1012
1013    #[test]
1014    fn paths_resolve_as_the_package_names_them() {
1015        assert_eq!(
1016            resolve("xl/", "worksheets/sheet1.xml"),
1017            "xl/worksheets/sheet1.xml"
1018        );
1019        assert_eq!(
1020            resolve("xl/", "/xl/worksheets/sheet1.xml"),
1021            "xl/worksheets/sheet1.xml"
1022        );
1023        assert_eq!(
1024            resolve("xl/worksheets/", "../tables/table1.xml"),
1025            "xl/tables/table1.xml"
1026        );
1027        assert_eq!(
1028            resolve("xl/worksheets/", "./a/../b.xml"),
1029            "xl/worksheets/b.xml"
1030        );
1031        assert_eq!(rels_of("xl/workbook.xml"), "xl/_rels/workbook.xml.rels");
1032        assert_eq!(
1033            rels_of("xl/worksheets/sheet1.xml"),
1034            "xl/worksheets/_rels/sheet1.xml.rels"
1035        );
1036        assert_eq!(
1037            file_name_of("file:///C:/Models/Rates%20Q1.xlsx"),
1038            "Rates Q1.xlsx"
1039        );
1040        assert_eq!(file_name_of("Rates.xlsx"), "Rates.xlsx");
1041        assert_eq!(file_name_of("..\\Shared\\Rates.xlsx"), "Rates.xlsx");
1042        assert_eq!(file_name_of("odd%2"), "odd%2");
1043    }
1044}