Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions src/formats/sheet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use crate::error::ConvertError;
use crate::model::{Block, Cell, Document, GridBuilder, Inline, TableKind};
use crate::shared::header::resolve_header_rows;
use crate::shared::text::clean_text;
use calamine::{Data, Dimensions, Reader, Sheets, open_workbook_auto_from_rs};
use calamine::{Data, Dimensions, Reader, SheetVisible, Sheets, open_workbook_auto_from_rs};
use std::collections::{HashMap, HashSet};
use std::io::Cursor;

Expand All @@ -25,12 +25,33 @@ pub fn parse(bytes: &[u8]) -> Result<Document, ConvertError> {
contained("workbook open", || open_workbook_auto_from_rs(Cursor::new(bytes)))?
.map_err(map_open_error)?;
let sheet_names = contained("sheet listing", || workbook.sheet_names().to_owned())?;
let multi_sheet = sheet_names.len() > 1;
// `sheets_metadata()` shares one backing `Vec<Sheet>` with `sheet_names()`,
// so the Nth metadata entry describes the Nth name - a name lookup would
// reintroduce the bug under re-ordering, only positional access is sound.
let metadata = contained("sheet metadata", || workbook.sheets_metadata().to_owned())?;
// The multi-sheet heading ("## <name>") is only useful when more than one
// sheet is actually shown: a single visible sheet inside a workbook full
// of hidden ones must not gain (or lose) its heading. Count visible sheets
// rather than all sheets so hidden ones stay invisible to this decision.
let multi_sheet = sheet_names
.iter()
.zip(metadata.iter())
.filter(|(_, m)| m.visible == SheetVisible::Visible)
.count()
> 1;
let merged = merged_regions(&mut workbook, &sheet_names)?;

let mut doc = Document::default();
let mut failed = 0usize;
for name in &sheet_names {
for (name, meta) in sheet_names.iter().zip(metadata.iter()) {
// A hidden or veryHidden sheet is not visible to an end user opening
// the workbook, so it must contribute no block at all - skip it before
// any heading or table is emitted. This runs before the read attempt,
// so a hidden sheet never counts as "unreadable" (hidden != unreadable)
// and an all-hidden workbook degrades to an empty Document, not an error.
if meta.visible != SheetVisible::Visible {
continue;
}
let range = match contained("worksheet read", || workbook.worksheet_range(name))? {
Ok(r) => r,
Err(e) => {
Expand Down
Binary file added tests/fixtures/xlsx/handmade-hidden.xlsx
Binary file not shown.
66 changes: 66 additions & 0 deletions tests/gen_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -914,6 +914,71 @@ def merged_xlsx():
])


# ---------------------------------------------------------------------------
# R-hidden-sheet: hidden / veryHidden worksheets must not render as visible
# content (sheet-level visibility, the half of #9 calamine exposes). A visible
# sheet keeps its table; a hidden and a veryHidden sheet drop out entirely.

def hidden_xlsx():
ct = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
<Override PartName="/xl/worksheets/sheet2.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
<Override PartName="/xl/worksheets/sheet3.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
</Types>"""
root_rels = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
</Relationships>"""
workbook = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets>
<sheet name="Visible" sheetId="1" r:id="rId1"/>
<sheet name="Hidden" sheetId="2" r:id="rId2" state="hidden"/>
<sheet name="VeryHidden" sheetId="3" r:id="rId3" state="veryHidden"/>
</sheets></workbook>"""
wb_rels = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet2.xml"/>
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet3.xml"/>
</Relationships>"""
sheet1 = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheetData><row r="1">
<c r="A1" t="inlineStr"><is><t>visible cell</t></is></c>
<c r="B1" t="inlineStr"><is><t>shown</t></is></c>
</row></sheetData>
</worksheet>"""
sheet2 = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheetData><row r="1">
<c r="A1" t="inlineStr"><is><t>hidden cell</t></is></c>
<c r="B1" t="inlineStr"><is><t>must not appear</t></is></c>
</row></sheetData>
</worksheet>"""
sheet3 = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheetData><row r="1">
<c r="A1" t="inlineStr"><is><t>very hidden cell</t></is></c>
<c r="B1" t="inlineStr"><is><t>must not appear either</t></is></c>
</row></sheetData>
</worksheet>"""
write_zip(OUT / "xlsx" / "handmade-hidden.xlsx", [
("[Content_Types].xml", ct),
("_rels/.rels", root_rels),
("xl/workbook.xml", workbook),
("xl/_rels/workbook.xml.rels", wb_rels),
("xl/worksheets/sheet1.xml", sheet1),
("xl/worksheets/sheet2.xml", sheet2),
("xl/worksheets/sheet3.xml", sheet3),
])


# ---------------------------------------------------------------------------
# R16: ODF style:default-style beneath named chains; full ISO durations

Expand Down Expand Up @@ -1748,6 +1813,7 @@ def main():
manyrefs_docx()
defaults_odf()
merged_xlsx()
hidden_xlsx()
features_epub()
bin_rtf()
csvs()
Expand Down
69 changes: 69 additions & 0 deletions tests/snapshots.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ mod common;

use common::{fixture_root, walk};
use std::fmt::Write as _;
use std::io::Write as _;
use std::path::Path;

/// Convert one file, capturing panics so a bad parser records a baseline
Expand Down Expand Up @@ -166,6 +167,74 @@ fn doc_inline_picture_is_retained() {
);
}

/// Hidden and veryHidden worksheets contribute no heading and no table; only
/// the visible sheet renders. This is the sheet-level half of #9 (calamine
/// exposes `state="hidden"`/`state="veryHidden"`; row/column visibility is a
/// separate, out-of-scope half). The committed fixture holds one visible sheet
/// plus a hidden and a veryHidden sheet, each with identifying cell content.
#[test]
fn hidden_worksheets_do_not_render() {
let path = fixture_root().join("xlsx").join("handmade-hidden.xlsx");
let bytes = std::fs::read(&path).unwrap();
let doc = anydoc::to_document(&bytes, anydoc::Format::Excel).unwrap();
// Exactly one table - the visible sheet's; no heading is emitted because a
// single visible sheet needs no "## <name>" disambiguator.
assert_eq!(doc.blocks.len(), 1, "only the visible sheet should render, got {:?}", doc.blocks);
assert!(matches!(doc.blocks[0], anydoc::model::Block::Table(_)));

// The hidden sheets' cell content and names must be absent from the
// rendered Markdown (no heading, no rendered table).
let md = anydoc::to_markdown(&path).unwrap();
assert!(md.contains("visible cell"), "visible content should render:\n{md}");
for needle in ["Hidden", "VeryHidden", "must not appear", "very hidden cell"] {
assert!(!md.contains(needle), "hidden content leaked into output ({needle}):\n{md}");
}
}

/// A workbook whose every sheet is hidden degrades to an empty Document, not a
/// panic and not a misleading "no sheet could be read" error (hidden is a valid
/// state, not unreadable). Built inline so no committed fixture is all-hidden.
#[test]
fn all_hidden_workbook_yields_empty_document() {
let bytes = all_hidden_xlsx();
let doc = anydoc::to_document(&bytes, anydoc::Format::Excel).unwrap();
assert!(doc.blocks.is_empty(), "an all-hidden workbook must render no blocks");
let md = anydoc::to_markdown_bytes(&bytes, anydoc::Format::Excel).unwrap();
assert!(md.trim().is_empty(), "an all-hidden workbook must render no markdown");
}

/// Minimal xlsx whose single sheet is `state="hidden"`, so every sheet is
/// non-visible. Mirrors the inline xlsx builder the sheet unit tests use.
fn all_hidden_xlsx() -> Vec<u8> {
let parts: &[(&str, &str)] = &[
(
"[Content_Types].xml",
r#"<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>"#,
),
(
"_rels/.rels",
r#"<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>"#,
),
(
"xl/workbook.xml",
r#"<?xml version="1.0"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Only" sheetId="1" r:id="rId1" state="hidden"/></sheets></workbook>"#,
),
(
"xl/_rels/workbook.xml.rels",
r#"<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>"#,
),
];
let sheet = r#"<?xml version="1.0"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>hidden only</t></is></c></row></sheetData></worksheet>"#;
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, body) in parts {
w.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
w.write_all(body.as_bytes()).unwrap();
}
w.start_file("xl/worksheets/sheet1.xml", zip::write::SimpleFileOptions::default()).unwrap();
w.write_all(sheet.as_bytes()).unwrap();
w.finish().unwrap().into_inner()
}

/// The RTF `\pict` payload is retained as an asset (the Markdown output
/// shows only the alt text, which is empty for pictures without one).
#[test]
Expand Down
7 changes: 7 additions & 0 deletions tests/snapshots/snapshots__xlsx__handmade-hidden.xlsx.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
source: tests/snapshots.rs
expression: output
---
| | |
| --- | --- |
| visible cell | shown |