Skip to main content

text_mirror/convert/
visibility.rs

1//! Row and column visibility, read beside calamine.
2//!
3//! calamine exposes sheet visibility but discards row and column
4//! visibility while parsing, so this layer reads it from the source
5//! itself: worksheet XML inside the OOXML archive, `content.xml`
6//! inside the ODF archive, and `ROW` and `COLINFO` records in the
7//! BIFF stream of a legacy workbook. Hidden means the hidden flag is
8//! set or the explicit custom height or width is zero.
9//!
10//! The readers fail closed. Element and attribute matching is
11//! namespace aware, so a foreign-namespace attribute can neither hide
12//! nor reveal anything. Worksheet parts are selected by relationship
13//! type, archive parts and streams read under the module ceilings,
14//! repeats and indexes use checked arithmetic against the sheet
15//! extent ceilings, and the reader reports exactly which sheets it
16//! resolved. The workbook converter refuses to render a sheet this
17//! layer did not resolve.
18
19use std::collections::HashMap;
20use std::io::{Cursor, Read};
21
22use quick_xml::NsReader;
23use quick_xml::XmlVersion;
24use quick_xml::encoding::Decoder;
25use quick_xml::events::{BytesStart, Event};
26use quick_xml::name::ResolveResult;
27
28use super::{MAX_PART_BYTES, MAX_SHEET_CELLS, MAX_SHEET_COLUMNS, MAX_SHEET_ROWS};
29
30const SPREADSHEET_NAMESPACES: [&[u8]; 2] = [
31    b"http://schemas.openxmlformats.org/spreadsheetml/2006/main",
32    b"http://purl.oclc.org/ooxml/spreadsheetml/main",
33];
34
35const PACKAGE_RELS_NAMESPACE: &[u8] =
36    b"http://schemas.openxmlformats.org/package/2006/relationships";
37
38const DOC_RELS_NAMESPACES: [&[u8]; 2] = [
39    b"http://schemas.openxmlformats.org/officeDocument/2006/relationships",
40    b"http://purl.oclc.org/ooxml/officeDocument/relationships",
41];
42
43const WORKSHEET_REL_TYPES: [&str; 2] = [
44    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet",
45    "http://purl.oclc.org/ooxml/officeDocument/relationships/worksheet",
46];
47
48const TABLE_NAMESPACE: &[u8] = b"urn:oasis:names:tc:opendocument:xmlns:table:1.0";
49
50const OFFICE_NAMESPACE: &[u8] = b"urn:oasis:names:tc:opendocument:xmlns:office:1.0";
51
52/// Why visibility could not be resolved.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum VisibilityError {
55    /// A module ceiling was exceeded. Maps to reason `resource_limit`.
56    Limit(String),
57    /// The file's visibility data is missing, damaged, or beyond this
58    /// reader. Maps to reason `visibility_read_error`.
59    Unresolved(String),
60}
61
62impl std::fmt::Display for VisibilityError {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        match self {
65            VisibilityError::Limit(detail) => write!(f, "{detail}"),
66            VisibilityError::Unresolved(detail) => write!(f, "{detail}"),
67        }
68    }
69}
70
71type VisResult<T> = std::result::Result<T, VisibilityError>;
72
73fn unresolved(detail: impl Into<String>) -> VisibilityError {
74    VisibilityError::Unresolved(detail.into())
75}
76
77fn limit(detail: impl Into<String>) -> VisibilityError {
78    VisibilityError::Limit(detail.into())
79}
80
81/// An inclusive range of 0-based row or column indexes.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub struct IndexRange {
84    /// First index covered.
85    pub first: u32,
86    /// Last index covered, inclusive.
87    pub last: u32,
88}
89
90/// Hidden rows and columns of one sheet, as index ranges.
91#[derive(Debug, Clone, Default, PartialEq)]
92pub struct SheetVisibility {
93    hidden_rows: Vec<IndexRange>,
94    hidden_columns: Vec<IndexRange>,
95}
96
97impl SheetVisibility {
98    /// True when the 0-based row is hidden.
99    pub fn row_hidden(&self, row: u32) -> bool {
100        self.hidden_rows
101            .iter()
102            .any(|r| r.first <= row && row <= r.last)
103    }
104
105    /// True when the 0-based column is hidden.
106    pub fn column_hidden(&self, column: u32) -> bool {
107        self.hidden_columns
108            .iter()
109            .any(|r| r.first <= column && column <= r.last)
110    }
111}
112
113/// Hidden rows and columns per resolved sheet name.
114#[derive(Debug, Clone, Default)]
115pub struct WorkbookVisibility {
116    by_sheet: HashMap<String, SheetVisibility>,
117}
118
119impl WorkbookVisibility {
120    /// The visibility data for a sheet this reader resolved. `None`
121    /// for any sheet the reader did not resolve as a worksheet, and
122    /// the caller must refuse to render such a sheet.
123    pub fn sheet(&self, name: &str) -> Option<SheetVisibility> {
124        self.by_sheet.get(name).cloned()
125    }
126}
127
128/// Reads row and column visibility for a workbook format.
129///
130/// `xlsx` and `xlsm` read worksheet XML, `ods` reads `content.xml`,
131/// and `xls` walks the BIFF stream. Any other format id is a caller
132/// error, because the registry routes formats without a reader to the
133/// interim unsupported rule before conversion starts.
134pub fn read_visibility(bytes: &[u8], format_id: &str) -> VisResult<WorkbookVisibility> {
135    match format_id {
136        "xlsx" | "xlsm" => read_ooxml(bytes),
137        "ods" => read_ods(bytes),
138        "xls" => read_biff(bytes),
139        other => Err(unresolved(format!(
140            "no visibility reader for format {other}"
141        ))),
142    }
143}
144
145fn zip_entry(archive: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str) -> VisResult<String> {
146    let entry = archive
147        .by_name(name)
148        .map_err(|e| unresolved(format!("archive part {name} unreadable: {e}")))?;
149    let mut text = String::new();
150    entry
151        .take(MAX_PART_BYTES + 1)
152        .read_to_string(&mut text)
153        .map_err(|e| unresolved(format!("archive part {name} unreadable: {e}")))?;
154    if text.len() as u64 > MAX_PART_BYTES {
155        return Err(limit(format!(
156            "archive part {name} exceeds the {MAX_PART_BYTES} byte ceiling"
157        )));
158    }
159    Ok(text)
160}
161
162/// How an attribute must be namespaced to count.
163enum AttrSpace<'a> {
164    /// No prefix. Unprefixed attributes belong to no namespace.
165    None,
166    /// Bound to one of these namespaces.
167    OneOf(&'a [&'a [u8]]),
168}
169
170fn attribute(
171    reader: &NsReader<&[u8]>,
172    start: &BytesStart,
173    space: AttrSpace,
174    local: &[u8],
175    decoder: Decoder,
176) -> VisResult<Option<String>> {
177    for attr in start.attributes() {
178        let attr = attr.map_err(|e| unresolved(format!("bad attribute: {e}")))?;
179        let (resolution, name) = reader.resolver().resolve_attribute(attr.key);
180        if name.as_ref() != local {
181            continue;
182        }
183        let matches = match &space {
184            AttrSpace::None => matches!(resolution, ResolveResult::Unbound),
185            AttrSpace::OneOf(namespaces) => match resolution {
186                ResolveResult::Bound(bound) => namespaces.iter().any(|ns| *ns == bound.as_ref()),
187                _ => false,
188            },
189        };
190        if !matches {
191            continue;
192        }
193        let value = attr
194            .decoded_and_normalized_value(XmlVersion::Implicit1_0, decoder)
195            .map_err(|e| unresolved(format!("bad attribute value: {e}")))?;
196        return Ok(Some(value.into_owned()));
197    }
198    Ok(None)
199}
200
201fn element_in(
202    reader: &NsReader<&[u8]>,
203    start: &BytesStart,
204    namespaces: &[&[u8]],
205    local: &[u8],
206) -> bool {
207    let (resolution, name) = reader.resolver().resolve_element(start.name());
208    if name.as_ref() != local {
209        return false;
210    }
211    match resolution {
212        ResolveResult::Bound(bound) => namespaces.iter().any(|ns| *ns == bound.as_ref()),
213        _ => false,
214    }
215}
216
217fn is_truthy(value: &str) -> bool {
218    value == "1" || value.eq_ignore_ascii_case("true")
219}
220
221fn parse_index(value: &str, what: &str) -> VisResult<u32> {
222    value
223        .parse::<u32>()
224        .map_err(|_| unresolved(format!("bad {what} value {value:?}")))
225}
226
227/// A size attribute is zero only when it parses to a finite zero.
228/// Anything unparseable or non-finite is a resolution error.
229fn parse_zero_size(value: &str, what: &str) -> VisResult<bool> {
230    let parsed = value
231        .parse::<f64>()
232        .map_err(|_| unresolved(format!("bad {what} value {value:?}")))?;
233    if !parsed.is_finite() {
234        return Err(unresolved(format!("bad {what} value {value:?}")));
235    }
236    Ok(parsed == 0.0)
237}
238
239/// Splits an A1-style reference into 0-based (row, column).
240fn parse_cell_reference(reference: &str) -> VisResult<(u32, u32)> {
241    let letters: String = reference
242        .chars()
243        .take_while(char::is_ascii_alphabetic)
244        .collect();
245    let digits = &reference[letters.len()..];
246    if letters.is_empty() || digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
247        return Err(unresolved(format!("bad cell reference {reference:?}")));
248    }
249    let mut column = 0u64;
250    for c in letters.chars() {
251        column = column * 26 + (c.to_ascii_uppercase() as u64 - 'A' as u64 + 1);
252        if column > u64::from(MAX_SHEET_COLUMNS) {
253            return Err(limit(format!(
254                "cell reference {reference:?} past the column ceiling"
255            )));
256        }
257    }
258    let row = digits
259        .parse::<u32>()
260        .map_err(|_| unresolved(format!("bad cell reference {reference:?}")))?;
261    if row == 0 || row > MAX_SHEET_ROWS {
262        return Err(limit(format!(
263            "cell reference {reference:?} past the row ceiling"
264        )));
265    }
266    Ok((row - 1, column as u32 - 1))
267}
268
269fn check_extent(rows: u32, columns: u32, sheet: &str) -> VisResult<()> {
270    if u64::from(rows) * u64::from(columns) > MAX_SHEET_CELLS {
271        return Err(limit(format!(
272            "sheet {sheet:?} extent {rows} rows by {columns} columns exceeds the {MAX_SHEET_CELLS} cell ceiling"
273        )));
274    }
275    Ok(())
276}
277
278/// Resolves an OPC part target against the `xl/` base, handling
279/// absolute targets and parent or current segments.
280fn normalize_part_target(target: &str) -> VisResult<String> {
281    let (mut stack, path): (Vec<&str>, &str) = match target.strip_prefix('/') {
282        Some(absolute) => (Vec::new(), absolute),
283        None => (vec!["xl"], target),
284    };
285    for segment in path.split('/') {
286        match segment {
287            "" | "." => {}
288            ".." => {
289                if stack.pop().is_none() {
290                    return Err(unresolved(format!(
291                        "part target {target:?} escapes the package"
292                    )));
293                }
294            }
295            other => stack.push(other),
296        }
297    }
298    if stack.is_empty() {
299        return Err(unresolved(format!("empty part target {target:?}")));
300    }
301    Ok(stack.join("/"))
302}
303
304// OOXML: xl/workbook.xml names the sheets, the workbook rels map each
305// sheet to its part by relationship type, and worksheet parts carry
306// row and col visibility attributes.
307fn read_ooxml(bytes: &[u8]) -> VisResult<WorkbookVisibility> {
308    let mut archive = zip::ZipArchive::new(Cursor::new(bytes))
309        .map_err(|e| unresolved(format!("not a zip archive: {e}")))?;
310
311    let workbook = zip_entry(&mut archive, "xl/workbook.xml")?;
312    let mut sheets: Vec<(String, String)> = Vec::new();
313    let mut reader = NsReader::from_str(&workbook);
314    let decoder = reader.decoder();
315    loop {
316        match reader.read_event() {
317            Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
318                if element_in(&reader, &e, &SPREADSHEET_NAMESPACES, b"sheet") {
319                    let name = attribute(&reader, &e, AttrSpace::None, b"name", decoder)?
320                        .ok_or_else(|| unresolved("sheet element without a name"))?;
321                    let rid = attribute(
322                        &reader,
323                        &e,
324                        AttrSpace::OneOf(&DOC_RELS_NAMESPACES),
325                        b"id",
326                        decoder,
327                    )?
328                    .ok_or_else(|| {
329                        unresolved(format!("sheet {name:?} without a relationship id"))
330                    })?;
331                    sheets.push((name, rid));
332                }
333            }
334            Ok(Event::Eof) => break,
335            Ok(_) => {}
336            Err(e) => return Err(unresolved(format!("malformed xl/workbook.xml: {e}"))),
337        }
338    }
339
340    let rels = zip_entry(&mut archive, "xl/_rels/workbook.xml.rels")?;
341    let mut targets: HashMap<String, (String, String)> = HashMap::new();
342    let mut reader = NsReader::from_str(&rels);
343    let decoder = reader.decoder();
344    loop {
345        match reader.read_event() {
346            Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
347                if element_in(&reader, &e, &[PACKAGE_RELS_NAMESPACE], b"Relationship") {
348                    let id = attribute(&reader, &e, AttrSpace::None, b"Id", decoder)?;
349                    let target = attribute(&reader, &e, AttrSpace::None, b"Target", decoder)?;
350                    let rel_type = attribute(&reader, &e, AttrSpace::None, b"Type", decoder)?;
351                    if let (Some(id), Some(target), Some(rel_type)) = (id, target, rel_type) {
352                        targets.insert(id, (target, rel_type));
353                    }
354                }
355            }
356            Ok(Event::Eof) => break,
357            Ok(_) => {}
358            Err(e) => return Err(unresolved(format!("malformed workbook rels: {e}"))),
359        }
360    }
361
362    let mut by_sheet = HashMap::new();
363    for (name, rid) in sheets {
364        let (target, rel_type) = targets
365            .get(&rid)
366            .ok_or_else(|| unresolved(format!("sheet {name:?} has no relationship target")))?;
367        if !WORKSHEET_REL_TYPES.contains(&rel_type.as_str()) {
368            // Chart, dialog, and macro sheet parts carry no cell grid
369            // this reader can resolve. They stay unresolved, and the
370            // converter refuses to render them if the parser disagrees.
371            continue;
372        }
373        let part = normalize_part_target(target)?;
374        let xml = zip_entry(&mut archive, &part)?;
375        by_sheet.insert(name.clone(), read_ooxml_worksheet(&xml, &part, &name)?);
376    }
377    Ok(WorkbookVisibility { by_sheet })
378}
379
380fn read_ooxml_worksheet(xml: &str, part: &str, sheet: &str) -> VisResult<SheetVisibility> {
381    let mut visibility = SheetVisibility::default();
382    let mut max_row = 0u32;
383    let mut max_column = 0u32;
384    let mut reader = NsReader::from_str(xml);
385    let decoder = reader.decoder();
386    loop {
387        match reader.read_event() {
388            Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
389                if element_in(&reader, &e, &SPREADSHEET_NAMESPACES, b"col") {
390                    let first = attribute(&reader, &e, AttrSpace::None, b"min", decoder)?
391                        .ok_or_else(|| unresolved(format!("{part}: col without min")))?;
392                    let last = attribute(&reader, &e, AttrSpace::None, b"max", decoder)?
393                        .ok_or_else(|| unresolved(format!("{part}: col without max")))?;
394                    let hidden = attribute(&reader, &e, AttrSpace::None, b"hidden", decoder)?
395                        .is_some_and(|v| is_truthy(&v));
396                    let zero_width = attribute(&reader, &e, AttrSpace::None, b"width", decoder)?
397                        .map(|w| parse_zero_size(&w, "col width"))
398                        .transpose()?
399                        .unwrap_or(false);
400                    let first = parse_index(&first, "col min")?;
401                    let last = parse_index(&last, "col max")?;
402                    if first == 0 || last < first || last > MAX_SHEET_COLUMNS {
403                        return Err(unresolved(format!("{part}: bad col range {first}..{last}")));
404                    }
405                    if hidden || zero_width {
406                        visibility.hidden_columns.push(IndexRange {
407                            first: first - 1,
408                            last: last - 1,
409                        });
410                    }
411                } else if element_in(&reader, &e, &SPREADSHEET_NAMESPACES, b"row") {
412                    let index = attribute(&reader, &e, AttrSpace::None, b"r", decoder)?;
413                    let hidden = attribute(&reader, &e, AttrSpace::None, b"hidden", decoder)?
414                        .is_some_and(|v| is_truthy(&v));
415                    let zero_height = attribute(&reader, &e, AttrSpace::None, b"ht", decoder)?
416                        .map(|h| parse_zero_size(&h, "row height"))
417                        .transpose()?
418                        .unwrap_or(false);
419                    if let Some(index) = index {
420                        let index = parse_index(&index, "row index")?;
421                        if index == 0 {
422                            return Err(unresolved(format!("{part}: row index 0")));
423                        }
424                        if index > MAX_SHEET_ROWS {
425                            return Err(limit(format!("{part}: row {index} past the row ceiling")));
426                        }
427                        max_row = max_row.max(index);
428                        if hidden || zero_height {
429                            visibility.hidden_rows.push(IndexRange {
430                                first: index - 1,
431                                last: index - 1,
432                            });
433                        }
434                    } else if hidden || zero_height {
435                        return Err(unresolved(format!("{part}: hidden row without an index")));
436                    }
437                } else if element_in(&reader, &e, &SPREADSHEET_NAMESPACES, b"c")
438                    && let Some(reference) = attribute(&reader, &e, AttrSpace::None, b"r", decoder)?
439                {
440                    let (row, column) = parse_cell_reference(&reference)?;
441                    max_row = max_row.max(row + 1);
442                    max_column = max_column.max(column + 1);
443                }
444            }
445            Ok(Event::Eof) => break,
446            Ok(_) => {}
447            Err(e) => return Err(unresolved(format!("malformed {part}: {e}"))),
448        }
449    }
450    check_extent(max_row, max_column, sheet)?;
451    Ok(visibility)
452}
453
454// ODF: content.xml lists every table with its columns and rows in
455// order. Visibility is the table:visibility attribute with values
456// collapse and filter, repeats widen the covered range with checked
457// arithmetic, and only top-level tables count as sheets.
458fn read_ods(bytes: &[u8]) -> VisResult<WorkbookVisibility> {
459    let mut archive = zip::ZipArchive::new(Cursor::new(bytes))
460        .map_err(|e| unresolved(format!("not a zip archive: {e}")))?;
461    let content = zip_entry(&mut archive, "content.xml")?;
462
463    struct OpenTable {
464        name: String,
465        visibility: SheetVisibility,
466        next_row: u32,
467        next_column: u32,
468        // Extent of cells that carry content, the bounding box the
469        // workbook parser densifies. Declared empty fillers to the
470        // sheet maximum are normal and stay outside this box.
471        max_content_row: u32,
472        max_content_column: u32,
473        row_cell_cursor: u32,
474    }
475
476    let mut by_sheet = HashMap::new();
477    let mut table_depth = 0u32;
478    let mut current: Option<OpenTable> = None;
479
480    let mut reader = NsReader::from_str(&content);
481    let decoder = reader.decoder();
482
483    fn repeat_of(
484        reader: &NsReader<&[u8]>,
485        e: &BytesStart,
486        local: &[u8],
487        decoder: Decoder,
488    ) -> VisResult<u32> {
489        let repeat = attribute(
490            reader,
491            e,
492            AttrSpace::OneOf(&[TABLE_NAMESPACE]),
493            local,
494            decoder,
495        )?
496        .map(|v| parse_index(&v, "repeat"))
497        .transpose()?
498        .unwrap_or(1);
499        if repeat == 0 {
500            return Err(unresolved("zero repeat count"));
501        }
502        Ok(repeat)
503    }
504
505    fn advance(cursor: u32, repeat: u32, ceiling: u32, what: &str) -> VisResult<u32> {
506        let next = cursor
507            .checked_add(repeat)
508            .ok_or_else(|| limit(format!("{what} repeat overflows")))?;
509        if next > ceiling {
510            return Err(limit(format!(
511                "{what} extent {next} exceeds the {ceiling} ceiling"
512            )));
513        }
514        Ok(next)
515    }
516
517    loop {
518        let event = reader
519            .read_event()
520            .map_err(|e| unresolved(format!("malformed content.xml: {e}")))?;
521        let (e, empty) = match &event {
522            Event::Start(e) => (e, false),
523            Event::Empty(e) => (e, true),
524            Event::End(e) => {
525                let (resolution, name) = reader.resolver().resolve_element(e.name());
526                let is_table = name.as_ref() == b"table"
527                    && matches!(&resolution, ResolveResult::Bound(b) if b.as_ref() == TABLE_NAMESPACE);
528                if is_table && table_depth > 0 {
529                    table_depth -= 1;
530                    if table_depth == 0
531                        && let Some(open) = current.take()
532                    {
533                        check_extent(open.max_content_row, open.max_content_column, &open.name)?;
534                        by_sheet.insert(open.name, open.visibility);
535                    }
536                }
537                continue;
538            }
539            Event::Eof => break,
540            _ => continue,
541        };
542
543        if element_in(&reader, e, &[TABLE_NAMESPACE], b"table") {
544            if table_depth == 0 {
545                let name = attribute(
546                    &reader,
547                    e,
548                    AttrSpace::OneOf(&[TABLE_NAMESPACE]),
549                    b"name",
550                    decoder,
551                )?
552                .ok_or_else(|| unresolved("table without a name"))?;
553                let open = OpenTable {
554                    name,
555                    visibility: SheetVisibility::default(),
556                    next_row: 0,
557                    next_column: 0,
558                    max_content_row: 0,
559                    max_content_column: 0,
560                    row_cell_cursor: 0,
561                };
562                if empty {
563                    by_sheet.insert(open.name, open.visibility);
564                } else {
565                    current = Some(open);
566                    table_depth = 1;
567                }
568            } else if !empty {
569                table_depth += 1;
570            }
571            continue;
572        }
573        if table_depth != 1 {
574            continue;
575        }
576        let Some(open) = current.as_mut() else {
577            continue;
578        };
579
580        if element_in(&reader, e, &[TABLE_NAMESPACE], b"table-column") {
581            let repeat = repeat_of(&reader, e, b"number-columns-repeated", decoder)?;
582            let hidden = attribute(
583                &reader,
584                e,
585                AttrSpace::OneOf(&[TABLE_NAMESPACE]),
586                b"visibility",
587                decoder,
588            )?
589            .is_some_and(|v| v == "collapse" || v == "filter");
590            let next = advance(open.next_column, repeat, MAX_SHEET_COLUMNS, "column")?;
591            if hidden {
592                open.visibility.hidden_columns.push(IndexRange {
593                    first: open.next_column,
594                    last: next - 1,
595                });
596            }
597            open.next_column = next;
598        } else if element_in(&reader, e, &[TABLE_NAMESPACE], b"table-row") {
599            let repeat = repeat_of(&reader, e, b"number-rows-repeated", decoder)?;
600            let hidden = attribute(
601                &reader,
602                e,
603                AttrSpace::OneOf(&[TABLE_NAMESPACE]),
604                b"visibility",
605                decoder,
606            )?
607            .is_some_and(|v| v == "collapse" || v == "filter");
608            let next = advance(open.next_row, repeat, MAX_SHEET_ROWS, "row")?;
609            if hidden {
610                open.visibility.hidden_rows.push(IndexRange {
611                    first: open.next_row,
612                    last: next - 1,
613                });
614            }
615            open.next_row = next;
616            open.row_cell_cursor = 0;
617        } else if element_in(&reader, e, &[TABLE_NAMESPACE], b"table-cell")
618            || element_in(&reader, e, &[TABLE_NAMESPACE], b"covered-table-cell")
619        {
620            let repeat = repeat_of(&reader, e, b"number-columns-repeated", decoder)?;
621            let next = advance(open.row_cell_cursor, repeat, MAX_SHEET_COLUMNS, "cell")?;
622            let has_content = attribute(
623                &reader,
624                e,
625                AttrSpace::OneOf(&[OFFICE_NAMESPACE]),
626                b"value-type",
627                decoder,
628            )?
629            .is_some();
630            open.row_cell_cursor = next;
631            if has_content {
632                open.max_content_column = open.max_content_column.max(next);
633                open.max_content_row = open.max_content_row.max(open.next_row);
634            }
635        }
636    }
637    if table_depth != 0 {
638        return Err(unresolved("content.xml ends inside a table"));
639    }
640    Ok(WorkbookVisibility { by_sheet })
641}
642
643// BIFF record opcodes the walk cares about.
644const BIFF_BOF: u16 = 0x0809;
645const BIFF_EOF: u16 = 0x000A;
646const BIFF_BOUNDSHEET: u16 = 0x0085;
647const BIFF_FILEPASS: u16 = 0x002F;
648const BIFF_CODEPAGE: u16 = 0x0042;
649const BIFF_ROW: u16 = 0x0208;
650const BIFF_COLINFO: u16 = 0x007D;
651
652const BOF_WORKSHEET: u16 = 0x0010;
653
654struct BiffRecord<'a> {
655    opcode: u16,
656    payload: &'a [u8],
657}
658
659/// Walks records from `from` until the substream's own EOF record.
660/// Truncated records, a missing EOF, and trailing garbage after a
661/// header fragment are errors, never a silent stop.
662struct BiffWalk<'a> {
663    stream: &'a [u8],
664    offset: usize,
665    done: bool,
666}
667
668impl<'a> BiffWalk<'a> {
669    fn new(stream: &'a [u8], from: usize) -> Self {
670        BiffWalk {
671            stream,
672            offset: from,
673            done: false,
674        }
675    }
676}
677
678impl<'a> Iterator for BiffWalk<'a> {
679    type Item = VisResult<BiffRecord<'a>>;
680
681    fn next(&mut self) -> Option<Self::Item> {
682        if self.done {
683            return None;
684        }
685        if self.offset + 4 > self.stream.len() {
686            self.done = true;
687            return Some(Err(unresolved(format!(
688                "BIFF stream ends without EOF at offset {}",
689                self.offset
690            ))));
691        }
692        let opcode = u16::from_le_bytes([self.stream[self.offset], self.stream[self.offset + 1]]);
693        let len = u16::from_le_bytes([self.stream[self.offset + 2], self.stream[self.offset + 3]])
694            as usize;
695        let start = self.offset + 4;
696        if start + len > self.stream.len() {
697            self.done = true;
698            return Some(Err(unresolved(format!(
699                "truncated BIFF record 0x{opcode:04X} at {}",
700                self.offset
701            ))));
702        }
703        self.offset = start + len;
704        if opcode == BIFF_EOF {
705            self.done = true;
706        }
707        Some(Ok(BiffRecord {
708            opcode,
709            payload: &self.stream[start..start + len],
710        }))
711    }
712}
713
714fn field_u16(payload: &[u8], at: usize, what: &str) -> VisResult<u16> {
715    if at + 2 > payload.len() {
716        return Err(unresolved(format!("short BIFF {what} record")));
717    }
718    Ok(u16::from_le_bytes([payload[at], payload[at + 1]]))
719}
720
721#[derive(Clone, Copy, PartialEq)]
722enum BiffVersion {
723    Biff8,
724    Biff5,
725}
726
727/// Decodes a narrow byte with the workbook codepage the way calamine
728/// does: ASCII directly, cp1252 through its table, and anything else
729/// is beyond this reader and fails closed.
730fn decode_narrow(bytes: &[u8], codepage: u16) -> VisResult<String> {
731    let mut out = String::with_capacity(bytes.len());
732    for byte in bytes {
733        if byte.is_ascii() {
734            out.push(*byte as char);
735        } else if codepage == 1252 {
736            out.push(cp1252_char(*byte));
737        } else {
738            return Err(unresolved(format!(
739                "sheet name in unsupported codepage {codepage}"
740            )));
741        }
742    }
743    Ok(out)
744}
745
746fn cp1252_char(byte: u8) -> char {
747    // The 0x80..0xA0 block is where cp1252 differs from Latin-1.
748    const HIGH: [char; 32] = [
749        '\u{20AC}', '\u{81}', '\u{201A}', '\u{192}', '\u{201E}', '\u{2026}', '\u{2020}',
750        '\u{2021}', '\u{2C6}', '\u{2030}', '\u{160}', '\u{2039}', '\u{152}', '\u{8D}', '\u{17D}',
751        '\u{8F}', '\u{90}', '\u{2018}', '\u{2019}', '\u{201C}', '\u{201D}', '\u{2022}', '\u{2013}',
752        '\u{2014}', '\u{2DC}', '\u{2122}', '\u{161}', '\u{203A}', '\u{153}', '\u{9D}', '\u{17E}',
753        '\u{178}',
754    ];
755    if (0x80..0xA0).contains(&byte) {
756        HIGH[(byte - 0x80) as usize]
757    } else {
758        byte as char
759    }
760}
761
762fn boundsheet_name(payload: &[u8], version: BiffVersion, codepage: u16) -> VisResult<String> {
763    match version {
764        BiffVersion::Biff8 => {
765            if payload.len() < 8 {
766                return Err(unresolved("short BOUNDSHEET record"));
767            }
768            let len = payload[6] as usize;
769            let wide = payload[7] & 0x01 != 0;
770            let name_bytes = &payload[8..];
771            if wide {
772                if name_bytes.len() < len * 2 {
773                    return Err(unresolved("short BOUNDSHEET name"));
774                }
775                let units: Vec<u16> = name_bytes[..len * 2]
776                    .chunks_exact(2)
777                    .map(|pair| u16::from_le_bytes([pair[0], pair[1]]))
778                    .collect();
779                String::from_utf16(&units).map_err(|_| unresolved("bad BOUNDSHEET name"))
780            } else {
781                if name_bytes.len() < len {
782                    return Err(unresolved("short BOUNDSHEET name"));
783                }
784                // BIFF8 compressed strings are UTF-16 low bytes.
785                Ok(name_bytes[..len].iter().map(|b| *b as char).collect())
786            }
787        }
788        BiffVersion::Biff5 => {
789            if payload.len() < 7 {
790                return Err(unresolved("short BOUNDSHEET record"));
791            }
792            let len = payload[6] as usize;
793            let name_bytes = &payload[7..];
794            if name_bytes.len() < len {
795                return Err(unresolved("short BOUNDSHEET name"));
796            }
797            decode_narrow(&name_bytes[..len], codepage)
798        }
799    }
800}
801
802// Legacy BIFF: the workbook globals name every sheet and the offset of
803// its substream, and each substream carries ROW and COLINFO records
804// with hidden flags and explicit sizes.
805fn read_biff(bytes: &[u8]) -> VisResult<WorkbookVisibility> {
806    let mut compound = cfb::CompoundFile::open(Cursor::new(bytes))
807        .map_err(|e| unresolved(format!("not a compound file: {e}")))?;
808    let stream_name = ["/Workbook", "/Book"]
809        .into_iter()
810        .find(|name| compound.exists(name))
811        .ok_or_else(|| unresolved("no Workbook stream"))?;
812    let mut stream = Vec::new();
813    compound
814        .open_stream(stream_name)
815        .map_err(|e| unresolved(format!("cannot open {stream_name}: {e}")))?
816        .take(MAX_PART_BYTES + 1)
817        .read_to_end(&mut stream)
818        .map_err(|e| unresolved(format!("cannot read {stream_name}: {e}")))?;
819    if stream.len() as u64 > MAX_PART_BYTES {
820        return Err(limit(format!(
821            "workbook stream exceeds the {MAX_PART_BYTES} byte ceiling"
822        )));
823    }
824
825    // Globals: version from the leading BOF, then sheet names and
826    // substream offsets, ending at the globals EOF.
827    let mut walker = BiffWalk::new(&stream, 0);
828    let first = walker
829        .next()
830        .ok_or_else(|| unresolved("empty workbook stream"))??;
831    if first.opcode != BIFF_BOF {
832        return Err(unresolved("workbook stream does not start with BOF"));
833    }
834    let version = match field_u16(first.payload, 0, "BOF")? {
835        0x0600 => BiffVersion::Biff8,
836        0x0500 => BiffVersion::Biff5,
837        other => {
838            return Err(unresolved(format!(
839                "unsupported BIFF version 0x{other:04X}"
840            )));
841        }
842    };
843    let mut codepage = 1252u16;
844    let mut sheets: Vec<(String, usize)> = Vec::new();
845    let mut saw_globals_eof = false;
846    for record in walker.by_ref() {
847        let record = record?;
848        match record.opcode {
849            BIFF_FILEPASS => return Err(unresolved("encrypted workbook")),
850            BIFF_CODEPAGE => codepage = field_u16(record.payload, 0, "CODEPAGE")?,
851            BIFF_BOUNDSHEET => {
852                if record.payload.len() < 6 {
853                    return Err(unresolved("short BOUNDSHEET record"));
854                }
855                let position = u32::from_le_bytes([
856                    record.payload[0],
857                    record.payload[1],
858                    record.payload[2],
859                    record.payload[3],
860                ]) as usize;
861                let sheet_type = record.payload[5];
862                if sheet_type == 0 {
863                    sheets.push((
864                        boundsheet_name(record.payload, version, codepage)?,
865                        position,
866                    ));
867                }
868            }
869            BIFF_EOF => saw_globals_eof = true,
870            _ => {}
871        }
872    }
873    if !saw_globals_eof {
874        return Err(unresolved("globals end without EOF"));
875    }
876    let globals_end = walker.offset;
877
878    // Substreams must tile the rest of the stream: no worksheet may
879    // start inside the globals, and after every substream ends at its
880    // own EOF, no bytes may remain past the last one.
881    let mut furthest_end = globals_end;
882    let mut by_sheet = HashMap::new();
883    for (name, position) in sheets {
884        if position < globals_end || position >= stream.len() {
885            return Err(unresolved(format!(
886                "sheet {name:?} substream offset {position} is outside the sheet area"
887            )));
888        }
889        let mut visibility = SheetVisibility::default();
890        let mut walker = BiffWalk::new(&stream, position);
891        let first = walker
892            .next()
893            .ok_or_else(|| unresolved(format!("sheet {name:?} substream is empty")))??;
894        if first.opcode != BIFF_BOF {
895            return Err(unresolved(format!(
896                "sheet {name:?} substream does not start with BOF"
897            )));
898        }
899        if field_u16(first.payload, 2, "BOF")? != BOF_WORKSHEET {
900            return Err(unresolved(format!(
901                "sheet {name:?} substream BOF is not a worksheet"
902            )));
903        }
904        let mut saw_eof = false;
905        for record in walker.by_ref() {
906            let record = record?;
907            match record.opcode {
908                BIFF_ROW => {
909                    let row = field_u16(record.payload, 0, "ROW")?;
910                    let height = field_u16(record.payload, 6, "ROW")?;
911                    let flags = field_u16(record.payload, 12, "ROW")?;
912                    let zero_height = height & 0x8000 == 0 && height & 0x7FFF == 0;
913                    if flags & 0x0020 != 0 || zero_height {
914                        visibility.hidden_rows.push(IndexRange {
915                            first: row as u32,
916                            last: row as u32,
917                        });
918                    }
919                }
920                BIFF_COLINFO => {
921                    let first = field_u16(record.payload, 0, "COLINFO")?;
922                    let last = field_u16(record.payload, 2, "COLINFO")?;
923                    let width = field_u16(record.payload, 4, "COLINFO")?;
924                    let flags = field_u16(record.payload, 8, "COLINFO")?;
925                    if first > last || last > 255 {
926                        return Err(unresolved(format!(
927                            "COLINFO range {first}..{last} outside the column limit"
928                        )));
929                    }
930                    if flags & 0x0001 != 0 || width == 0 {
931                        visibility.hidden_columns.push(IndexRange {
932                            first: first as u32,
933                            last: last as u32,
934                        });
935                    }
936                }
937                BIFF_EOF => saw_eof = true,
938                _ => {}
939            }
940        }
941        if !saw_eof {
942            return Err(unresolved(format!("sheet {name:?} substream has no EOF")));
943        }
944        furthest_end = furthest_end.max(walker.offset);
945        by_sheet.insert(name, visibility);
946    }
947    if furthest_end != stream.len() {
948        return Err(unresolved(format!(
949            "{} trailing bytes after the last substream",
950            stream.len() - furthest_end
951        )));
952    }
953    Ok(WorkbookVisibility { by_sheet })
954}
955
956#[cfg(test)]
957mod tests {
958    use super::*;
959
960    pub(super) fn record(opcode: u16, payload: &[u8]) -> Vec<u8> {
961        let mut out = Vec::new();
962        out.extend_from_slice(&opcode.to_le_bytes());
963        out.extend_from_slice(&(payload.len() as u16).to_le_bytes());
964        out.extend_from_slice(payload);
965        out
966    }
967
968    fn bof(version: u16, dt: u16) -> Vec<u8> {
969        let mut payload = vec![0u8; 16];
970        payload[0..2].copy_from_slice(&version.to_le_bytes());
971        payload[2..4].copy_from_slice(&dt.to_le_bytes());
972        record(BIFF_BOF, &payload)
973    }
974
975    fn row_record(row: u16, height: u16, flags: u16) -> Vec<u8> {
976        let mut payload = vec![0u8; 16];
977        payload[0..2].copy_from_slice(&row.to_le_bytes());
978        payload[6..8].copy_from_slice(&height.to_le_bytes());
979        payload[12..14].copy_from_slice(&flags.to_le_bytes());
980        record(BIFF_ROW, &payload)
981    }
982
983    fn colinfo_record(first: u16, last: u16, width: u16, flags: u16) -> Vec<u8> {
984        let mut payload = vec![0u8; 12];
985        payload[0..2].copy_from_slice(&first.to_le_bytes());
986        payload[2..4].copy_from_slice(&last.to_le_bytes());
987        payload[4..6].copy_from_slice(&width.to_le_bytes());
988        payload[8..10].copy_from_slice(&flags.to_le_bytes());
989        record(BIFF_COLINFO, &payload)
990    }
991
992    fn boundsheet_biff8(name: &str, position: u32) -> Vec<u8> {
993        let mut payload = vec![0u8; 8];
994        payload[0..4].copy_from_slice(&position.to_le_bytes());
995        payload[6] = name.len() as u8;
996        payload.extend_from_slice(name.as_bytes());
997        record(BIFF_BOUNDSHEET, &payload)
998    }
999
1000    /// A BIFF8 workbook stream with one sheet named Data.
1001    fn biff_stream(sheet_records: &[Vec<u8>]) -> Vec<u8> {
1002        let globals = [
1003            bof(0x0600, 0x0005),
1004            boundsheet_biff8("Data", 0),
1005            record(BIFF_EOF, &[]),
1006        ];
1007        let globals_len: usize = globals.iter().map(Vec::len).sum();
1008        let mut stream = Vec::new();
1009        stream.extend(bof(0x0600, 0x0005));
1010        stream.extend(boundsheet_biff8("Data", globals_len as u32));
1011        stream.extend(record(BIFF_EOF, &[]));
1012        stream.extend(bof(0x0600, BOF_WORKSHEET));
1013        for sheet_record in sheet_records {
1014            stream.extend(sheet_record.clone());
1015        }
1016        stream.extend(record(BIFF_EOF, &[]));
1017        stream
1018    }
1019
1020    fn biff_file(stream: &[u8]) -> Vec<u8> {
1021        let cursor = Cursor::new(Vec::new());
1022        let mut compound = cfb::CompoundFile::create(cursor).unwrap();
1023        {
1024            use std::io::Write;
1025            let mut entry = compound.create_stream("/Workbook").unwrap();
1026            entry.write_all(stream).unwrap();
1027        }
1028        compound.into_inner().into_inner()
1029    }
1030
1031    #[test]
1032    fn biff_walk_finds_hidden_rows_and_columns() {
1033        let stream = biff_stream(&[
1034            row_record(0, 300, 0x0000),
1035            row_record(3, 300, 0x0020),
1036            row_record(5, 0x0000, 0x0000),
1037            colinfo_record(1, 2, 2048, 0x0001),
1038            colinfo_record(4, 4, 0, 0x0000),
1039        ]);
1040        let visibility = read_biff(&biff_file(&stream)).unwrap();
1041        let sheet = visibility.sheet("Data").unwrap();
1042        assert!(!sheet.row_hidden(0));
1043        assert!(sheet.row_hidden(3));
1044        assert!(sheet.row_hidden(5));
1045        assert!(sheet.column_hidden(1));
1046        assert!(sheet.column_hidden(2));
1047        assert!(!sheet.column_hidden(3));
1048        assert!(sheet.column_hidden(4));
1049    }
1050
1051    #[test]
1052    fn truncated_biff_stream_names_the_defect() {
1053        let mut stream = biff_stream(&[row_record(3, 300, 0x0020)]);
1054        stream.truncate(stream.len() - 7);
1055        let err = read_biff(&biff_file(&stream)).unwrap_err();
1056        assert!(err.to_string().contains("truncated"));
1057    }
1058
1059    #[test]
1060    fn trailing_bytes_after_the_last_substream_are_an_error() {
1061        let mut stream = biff_stream(&[row_record(3, 300, 0x0020)]);
1062        stream.extend_from_slice(&[0x00, 0x01, 0x02]);
1063        let err = read_biff(&biff_file(&stream)).unwrap_err();
1064        assert!(err.to_string().contains("trailing bytes"), "{err}");
1065    }
1066
1067    #[test]
1068    fn misdirected_substream_offset_is_refused() {
1069        // Point the BOUNDSHEET at a ROW record instead of a BOF.
1070        let globals = [
1071            bof(0x0600, 0x0005),
1072            boundsheet_biff8("Data", 0),
1073            record(BIFF_EOF, &[]),
1074        ];
1075        let globals_len: usize = globals.iter().map(Vec::len).sum();
1076        let row = row_record(3, 300, 0x0020);
1077        let target = globals_len + bof(0x0600, BOF_WORKSHEET).len();
1078
1079        let mut stream = Vec::new();
1080        stream.extend(bof(0x0600, 0x0005));
1081        stream.extend(boundsheet_biff8("Data", target as u32));
1082        stream.extend(record(BIFF_EOF, &[]));
1083        stream.extend(bof(0x0600, BOF_WORKSHEET));
1084        stream.extend(row);
1085        stream.extend(record(BIFF_EOF, &[]));
1086
1087        let err = read_biff(&biff_file(&stream)).unwrap_err();
1088        assert!(err.to_string().contains("does not start with BOF"), "{err}");
1089    }
1090
1091    #[test]
1092    fn substream_inside_the_globals_is_refused() {
1093        let mut stream = Vec::new();
1094        stream.extend(bof(0x0600, 0x0005));
1095        stream.extend(boundsheet_biff8("Data", 0));
1096        stream.extend(record(BIFF_EOF, &[]));
1097        stream.extend(bof(0x0600, BOF_WORKSHEET));
1098        stream.extend(record(BIFF_EOF, &[]));
1099        let err = read_biff(&biff_file(&stream)).unwrap_err();
1100        assert!(err.to_string().contains("outside the sheet area"), "{err}");
1101    }
1102
1103    #[test]
1104    fn biff5_names_parse_with_their_own_layout() {
1105        // BIFF5 BOUNDSHEET: position, visibility, type, length, bytes.
1106        let mut boundsheet = vec![0u8; 7];
1107        boundsheet[6] = 4;
1108        boundsheet.extend_from_slice(b"Data");
1109        let globals = [
1110            bof(0x0500, 0x0005),
1111            record(BIFF_BOUNDSHEET, &boundsheet),
1112            record(BIFF_EOF, &[]),
1113        ];
1114        let globals_len: usize = globals.iter().map(Vec::len).sum();
1115        let mut patched = vec![0u8; 7];
1116        patched[0..4].copy_from_slice(&(globals_len as u32).to_le_bytes());
1117        patched[6] = 4;
1118        patched.extend_from_slice(b"Data");
1119
1120        let mut stream = Vec::new();
1121        stream.extend(bof(0x0500, 0x0005));
1122        stream.extend(record(BIFF_BOUNDSHEET, &patched));
1123        stream.extend(record(BIFF_EOF, &[]));
1124        stream.extend(bof(0x0500, BOF_WORKSHEET));
1125        stream.extend(row_record(2, 300, 0x0020));
1126        stream.extend(record(BIFF_EOF, &[]));
1127
1128        let visibility = read_biff(&biff_file(&stream)).unwrap();
1129        assert!(visibility.sheet("Data").unwrap().row_hidden(2));
1130    }
1131
1132    #[test]
1133    fn encrypted_biff_stream_is_refused() {
1134        let mut stream = Vec::new();
1135        stream.extend(bof(0x0600, 0x0005));
1136        stream.extend(record(BIFF_FILEPASS, &[0u8; 6]));
1137        stream.extend(record(BIFF_EOF, &[]));
1138        let err = read_biff(&biff_file(&stream)).unwrap_err();
1139        assert!(err.to_string().contains("encrypted"));
1140    }
1141
1142    #[test]
1143    fn colinfo_past_the_column_limit_is_refused() {
1144        let stream = biff_stream(&[colinfo_record(5, 3, 2048, 0x0001)]);
1145        let err = read_biff(&biff_file(&stream)).unwrap_err();
1146        assert!(err.to_string().contains("COLINFO"), "{err}");
1147    }
1148
1149    fn zip_of(entries: &[(&str, &[u8])]) -> Vec<u8> {
1150        use std::io::Write;
1151        let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
1152        for (name, bytes) in entries {
1153            writer
1154                .start_file(*name, zip::write::SimpleFileOptions::default())
1155                .unwrap();
1156            writer.write_all(bytes).unwrap();
1157        }
1158        writer.finish().unwrap().into_inner()
1159    }
1160
1161    fn ods_of(content: &str) -> Vec<u8> {
1162        zip_of(&[
1163            (
1164                "mimetype",
1165                b"application/vnd.oasis.opendocument.spreadsheet",
1166            ),
1167            ("content.xml", content.as_bytes()),
1168        ])
1169    }
1170
1171    const ODS_OPEN: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
1172<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:evil="urn:example:evil">
1173<office:body><office:spreadsheet>"#;
1174    const ODS_CLOSE: &str = "</office:spreadsheet></office:body></office:document-content>";
1175
1176    #[test]
1177    fn ods_repeat_overflow_is_a_limit_error_not_a_panic() {
1178        let content = format!(
1179            "{ODS_OPEN}<table:table table:name=\"D\">\
1180<table:table-row table:number-rows-repeated=\"4294967290\"/>\
1181<table:table-row table:number-rows-repeated=\"4294967290\"/>\
1182</table:table>{ODS_CLOSE}"
1183        );
1184        let err = read_ods(&ods_of(&content)).unwrap_err();
1185        assert!(matches!(err, VisibilityError::Limit(_)), "{err}");
1186    }
1187
1188    #[test]
1189    fn ods_zero_repeat_is_refused() {
1190        let content = format!(
1191            "{ODS_OPEN}<table:table table:name=\"D\">\
1192<table:table-row table:number-rows-repeated=\"0\"/>\
1193</table:table>{ODS_CLOSE}"
1194        );
1195        let err = read_ods(&ods_of(&content)).unwrap_err();
1196        assert!(err.to_string().contains("zero repeat"), "{err}");
1197    }
1198
1199    #[test]
1200    fn ods_spoofed_foreign_visibility_changes_nothing() {
1201        // A foreign visibility attribute neither hides nor reveals.
1202        let content = format!(
1203            "{ODS_OPEN}<table:table table:name=\"D\">\
1204<table:table-row evil:visibility=\"collapse\"/>\
1205<table:table-row evil:visibility=\"visible\" table:visibility=\"collapse\"/>\
1206</table:table>{ODS_CLOSE}"
1207        );
1208        let sheet = read_ods(&ods_of(&content)).unwrap().sheet("D").unwrap();
1209        assert!(!sheet.row_hidden(0));
1210        assert!(sheet.row_hidden(1));
1211    }
1212
1213    #[test]
1214    fn ods_nested_tables_neither_open_sheets_nor_shift_rows() {
1215        // A nested table, foreign or table-namespaced, must not steal
1216        // the outer sheet's remaining rows.
1217        let content = format!(
1218            "{ODS_OPEN}<table:table table:name=\"Outer\">\
1219<table:table-row/>\
1220<evil:table><table:table-row table:visibility=\"collapse\"/></evil:table>\
1221<table:table><table:table-row table:visibility=\"collapse\"/></table:table>\
1222<table:table-row table:visibility=\"collapse\"/>\
1223</table:table>{ODS_CLOSE}"
1224        );
1225        let visibility = read_ods(&ods_of(&content)).unwrap();
1226        let outer = visibility.sheet("Outer").unwrap();
1227        assert!(!outer.row_hidden(0));
1228        assert!(outer.row_hidden(2), "outer hidden row keeps its index");
1229        assert!(
1230            visibility.sheet("").is_none(),
1231            "nested table is not a sheet"
1232        );
1233    }
1234
1235    fn xlsx_of(sheet_xml: &str) -> Vec<u8> {
1236        let workbook = r#"<?xml version="1.0"?>
1237<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
1238<sheets><sheet name="Data" sheetId="1" r:id="rId1"/></sheets></workbook>"#;
1239        let rels = r#"<?xml version="1.0"?>
1240<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
1241<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
1242</Relationships>"#;
1243        zip_of(&[
1244            ("xl/workbook.xml", workbook.as_bytes()),
1245            ("xl/_rels/workbook.xml.rels", rels.as_bytes()),
1246            ("xl/worksheets/sheet1.xml", sheet_xml.as_bytes()),
1247        ])
1248    }
1249
1250    const SHEET_OPEN: &str = r#"<?xml version="1.0"?>
1251<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:evil="urn:example:evil"><sheetData>"#;
1252    const SHEET_CLOSE: &str = "</sheetData></worksheet>";
1253
1254    #[test]
1255    fn xlsx_spoofed_foreign_hidden_changes_nothing() {
1256        let sheet = format!(
1257            "{SHEET_OPEN}<row r=\"1\" evil:hidden=\"1\"/>\
1258<row r=\"2\" evil:hidden=\"0\" hidden=\"1\"/>{SHEET_CLOSE}"
1259        );
1260        let visibility = read_ooxml(&xlsx_of(&sheet)).unwrap();
1261        let data = visibility.sheet("Data").unwrap();
1262        assert!(!data.row_hidden(0));
1263        assert!(data.row_hidden(1));
1264    }
1265
1266    #[test]
1267    fn xlsx_cell_extent_product_is_refused() {
1268        let sheet = format!(
1269            "{SHEET_OPEN}<row r=\"1\"><c r=\"A1\"><v>1</v></c></row>\
1270<row r=\"1048576\"><c r=\"XFD1048576\"><v>2</v></c></row>{SHEET_CLOSE}"
1271        );
1272        let err = read_ooxml(&xlsx_of(&sheet)).unwrap_err();
1273        assert!(matches!(err, VisibilityError::Limit(_)), "{err}");
1274    }
1275
1276    #[test]
1277    fn xlsx_non_numeric_sizes_are_refused() {
1278        let sheet = format!("{SHEET_OPEN}<row r=\"1\" ht=\"NaN\"/>{SHEET_CLOSE}");
1279        let err = read_ooxml(&xlsx_of(&sheet)).unwrap_err();
1280        assert!(err.to_string().contains("row height"), "{err}");
1281
1282        let sheet = r#"<?xml version="1.0"?>
1283<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
1284<cols><col min="1" max="1" width="wide"/></cols><sheetData/></worksheet>"#;
1285        let err = read_ooxml(&xlsx_of(sheet)).unwrap_err();
1286        assert!(err.to_string().contains("col width"), "{err}");
1287    }
1288
1289    #[test]
1290    fn worksheet_at_an_unusual_part_path_resolves_by_relationship_type() {
1291        let workbook = r#"<?xml version="1.0"?>
1292<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
1293<sheets><sheet name="Data" sheetId="1" r:id="rId1"/></sheets></workbook>"#;
1294        let rels = r#"<?xml version="1.0"?>
1295<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
1296<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="parts/sheet1.xml"/>
1297</Relationships>"#;
1298        let sheet = r#"<?xml version="1.0"?>
1299<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
1300<sheetData><row r="2" hidden="1"/></sheetData></worksheet>"#;
1301        let archive = zip_of(&[
1302            ("xl/workbook.xml", workbook.as_bytes()),
1303            ("xl/_rels/workbook.xml.rels", rels.as_bytes()),
1304            ("xl/parts/sheet1.xml", sheet.as_bytes()),
1305        ]);
1306        let visibility = read_ooxml(&archive).unwrap();
1307        assert!(visibility.sheet("Data").unwrap().row_hidden(1));
1308    }
1309
1310    #[test]
1311    fn oversized_archive_part_is_a_limit_error() {
1312        // A tiny compressed archive holding one part far over the
1313        // ceiling stays under the ceiling in memory and fails closed.
1314        let mut huge = String::with_capacity(MAX_PART_BYTES as usize + 1024);
1315        huge.push_str(r#"<?xml version="1.0"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>"#);
1316        while huge.len() as u64 <= MAX_PART_BYTES {
1317            huge.push_str("                                                                ");
1318        }
1319        huge.push_str("</sheetData></worksheet>");
1320        let err = read_ooxml(&xlsx_of(&huge)).unwrap_err();
1321        assert!(matches!(err, VisibilityError::Limit(_)), "{err}");
1322    }
1323
1324    #[test]
1325    fn unknown_format_has_no_reader() {
1326        let err = read_visibility(b"", "xlsb").unwrap_err();
1327        assert!(err.to_string().contains("no visibility reader"));
1328    }
1329
1330    #[test]
1331    fn unresolved_sheet_lookup_returns_none() {
1332        let visibility = WorkbookVisibility::default();
1333        assert!(visibility.sheet("Ghost").is_none());
1334    }
1335
1336    #[test]
1337    fn cell_references_parse_and_cap() {
1338        assert_eq!(parse_cell_reference("A1").unwrap(), (0, 0));
1339        assert_eq!(
1340            parse_cell_reference("XFD1048576").unwrap(),
1341            (1048575, 16383)
1342        );
1343        assert!(parse_cell_reference("XFE1").is_err());
1344        assert!(parse_cell_reference("A0").is_err());
1345        assert!(parse_cell_reference("11").is_err());
1346    }
1347
1348    #[test]
1349    fn part_targets_normalize() {
1350        assert_eq!(
1351            normalize_part_target("worksheets/sheet1.xml").unwrap(),
1352            "xl/worksheets/sheet1.xml"
1353        );
1354        assert_eq!(
1355            normalize_part_target("/xl/parts/sheet1.xml").unwrap(),
1356            "xl/parts/sheet1.xml"
1357        );
1358        assert_eq!(
1359            normalize_part_target("./sub/../parts/s.xml").unwrap(),
1360            "xl/parts/s.xml"
1361        );
1362        assert!(normalize_part_target("../../evil.xml").is_err());
1363    }
1364}