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
114 changes: 105 additions & 9 deletions src/formats/sheet/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
//! Excel spreadsheets (xlsx, xlsm, xlsb, xls) via calamine.
//!
//! For xlsx-like OOXML packages, hidden rows and columns declared in each
//! worksheet part (`<row hidden>` / `<col hidden>`) are omitted by default.
//! Binary xls/xlsb and collapsed outline / zero-size axes are out of scope
//! (see `visibility`).

mod visibility;

use crate::error::ConvertError;
use crate::model::{Block, Cell, Document, GridBuilder, Inline, TableKind};
Expand All @@ -7,6 +14,7 @@ use crate::shared::text::clean_text;
use calamine::{Data, Dimensions, Reader, Sheets, open_workbook_auto_from_rs};
use std::collections::{HashMap, HashSet};
use std::io::Cursor;
use visibility::SheetHidden;

/// Run one calamine operation behind a panic barrier: the calamine fork can
/// panic on corrupt containers (pending an upstream fix), and a dependency
Expand All @@ -27,6 +35,8 @@ pub fn parse(bytes: &[u8]) -> Result<Document, ConvertError> {
let sheet_names = contained("sheet listing", || workbook.sheet_names().to_owned())?;
let multi_sheet = sheet_names.len() > 1;
let merged = merged_regions(&mut workbook, &sheet_names)?;
// OOXML-only: binary containers yield an empty map and keep prior behavior.
let hidden_by_sheet = visibility::load_hidden_axes(bytes);

let mut doc = Document::default();
let mut failed = 0usize;
Expand All @@ -42,6 +52,7 @@ pub fn parse(bytes: &[u8]) -> Result<Document, ConvertError> {
if range.is_empty() {
continue;
}
let hidden = hidden_by_sheet.get(name.as_str());
// Merged regions in range-relative coordinates: the top-left cell
// becomes a spanning origin, the other positions are covered.
let start = range.start().unwrap_or((0, 0));
Expand All @@ -65,22 +76,51 @@ pub fn parse(bytes: &[u8]) -> Result<Document, ConvertError> {
let c0 = (col0 - start.1) as usize;
let r1 = (row_end - start.0 as u64) as usize;
let c1 = (col_end - start.1 as u64) as usize;
if r1 - r0 == 1 && c1 - c0 == 1 {
let visible_rows: Vec<usize> =
(r0..r1).filter(|&r| !axis_row_hidden(hidden, start.0 + r as u32)).collect();
let visible_cols: Vec<usize> =
(c0..c1).filter(|&c| !axis_col_hidden(hidden, start.1 + c as u32)).collect();
if visible_rows.is_empty() || visible_cols.is_empty() {
continue;
}
// Value lives at the merge origin; if that axis is hidden, drop
// the merge so remaining visible covered cells stay empty.
if axis_row_hidden(hidden, start.0 + r0 as u32)
|| axis_col_hidden(hidden, start.1 + c0 as u32)
{
continue;
}
Comment on lines +88 to +92

@cubic-dev-ai cubic-dev-ai Bot Aug 13, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a merged range has a hidden top-left row or column, this continue bypasses registration of its visible covered cells. Any raw values in those cells then leak into the document and Markdown; blank or suppress the visible covered positions while dropping the hidden origin.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/formats/sheet/mod.rs, line 88:

<comment>When a merged range has a hidden top-left row or column, this `continue` bypasses registration of its visible covered cells. Any raw values in those cells then leak into the document and Markdown; blank or suppress the visible covered positions while dropping the hidden origin.</comment>

<file context>
@@ -65,22 +76,51 @@ pub fn parse(bytes: &[u8]) -> Result<Document, ConvertError> {
+            }
+            // Value lives at the merge origin; if that axis is hidden, drop
+            // the merge so remaining visible covered cells stay empty.
+            if axis_row_hidden(hidden, start.0 + r0 as u32)
+                || axis_col_hidden(hidden, start.1 + c0 as u32)
+            {
</file context>
Suggested change
if axis_row_hidden(hidden, start.0 + r0 as u32)
|| axis_col_hidden(hidden, start.1 + c0 as u32)
{
continue;
}
if axis_row_hidden(hidden, start.0 + r0 as u32)
|| axis_col_hidden(hidden, start.1 + c0 as u32)
{
for &r in &visible_rows {
for &c in &visible_cols {
covered.insert((r, c));
}
}
continue;
}
Fix with cubic

let row_span = visible_rows.len() as u32;
let col_span = visible_cols.len() as u32;
if row_span == 1 && col_span == 1 {
continue;
}
origins.insert((r0, c0), ((c1 - c0) as u32, (r1 - r0) as u32));
for r in r0..r1 {
for c in c0..c1 {
origins.insert((r0, c0), (col_span, row_span));
for &r in &visible_rows {
for &c in &visible_cols {
if (r, c) != (r0, c0) {
covered.insert((r, c));
}
}
}
}
let mut builder = GridBuilder::new();
let any_visible_col =
(0..width).any(|c| !axis_col_hidden(hidden, start.1.saturating_add(c as u32)));
if !any_visible_col {
continue;
}
for (r, row) in range.rows().enumerate() {
let abs_row = start.0.saturating_add(r as u32);
if axis_row_hidden(hidden, abs_row) {
continue;
}
builder.next_row();
for (c, data) in row.iter().enumerate() {
let abs_col = start.1.saturating_add(c as u32);
if axis_col_hidden(hidden, abs_col) {
continue;
}
if covered.contains(&(r, c)) {
builder.covered();
continue;
Expand Down Expand Up @@ -116,6 +156,14 @@ pub fn parse(bytes: &[u8]) -> Result<Document, ConvertError> {
Ok(doc)
}

fn axis_row_hidden(hidden: Option<&SheetHidden>, abs_row: u32) -> bool {
hidden.is_some_and(|h| h.row_hidden(abs_row))
}

fn axis_col_hidden(hidden: Option<&SheetHidden>, abs_col: u32) -> bool {
hidden.is_some_and(|h| h.col_hidden(abs_col))
}

/// Merged regions per sheet, where the container format exposes them (xlsx
/// via each worksheet's mergeCells part, xls via BIFF MERGEDCELLS).
fn merged_regions<RS: std::io::Read + std::io::Seek>(
Expand Down Expand Up @@ -209,13 +257,23 @@ fn format_duration_days(days: f64) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::Format;
use crate::to_markdown_bytes;
use std::io::Write;

/// Minimal xlsx with a used range at D11:E12 and the given merged region.
fn xlsx_with_merge(merge_ref: &str) -> Vec<u8> {
let sheet = format!(
r#"<?xml version="1.0"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData><row r="11"><c r="D11" t="inlineStr"><is><t>x</t></is></c><c r="E11" t="inlineStr"><is><t>y</t></is></c></row><row r="12"><c r="D12" t="inlineStr"><is><t>z</t></is></c><c r="E12" t="inlineStr"><is><t>w</t></is></c></row></sheetData><mergeCells count="1"><mergeCell ref="{merge_ref}"/></mergeCells></worksheet>"#
);
pack_xlsx("S", &sheet)
}

/// Minimal single-sheet xlsx with the given worksheet body.
fn pack_xlsx(sheet_name: &str, sheet_xml: &str) -> Vec<u8> {
let workbook = format!(
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="{sheet_name}" sheetId="1" r:id="rId1"/></sheets></workbook>"#
);
let parts: &[(&str, &str)] = &[
(
"[Content_Types].xml",
Expand All @@ -225,10 +283,7 @@ mod tests {
"_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="S" sheetId="1" r:id="rId1"/></sheets></workbook>"#,
),
("xl/workbook.xml", workbook.as_str()),
(
"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>"#,
Expand All @@ -240,7 +295,7 @@ mod tests {
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.write_all(sheet_xml.as_bytes()).unwrap();
w.finish().unwrap().into_inner()
}

Expand Down Expand Up @@ -270,6 +325,47 @@ mod tests {
assert_eq!(covered_count(&doc), 0, "out-of-range merge must not cover cells");
}

#[test]
fn hidden_rows_and_columns_are_omitted() {
// Mirrors the issue #9 openpyxl repro: visible A1, hidden row 2, hidden col B.
let sheet = r#"<?xml version="1.0"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><cols><col min="2" max="2" hidden="1" width="10"/></cols><sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>VISIBLE_CELL</t></is></c><c r="B1" t="inlineStr"><is><t>HIDDEN_COL_TEXT</t></is></c></row><row r="2" hidden="1"><c r="A2" t="inlineStr"><is><t>HIDDEN_ROW_TEXT</t></is></c><c r="B2" t="inlineStr"><is><t>HIDDEN_BOTH</t></is></c></row></sheetData></worksheet>"#;
let bytes = pack_xlsx("Visibility", sheet);
let doc = parse(&bytes).unwrap();
let md = to_markdown_bytes(&bytes, Format::Excel).unwrap();
assert!(md.contains("VISIBLE_CELL"), "visible cell missing from markdown:\n{md}");
assert!(!md.contains("HIDDEN_ROW_TEXT"), "hidden row leaked into markdown:\n{md}");
assert!(!md.contains("HIDDEN_COL_TEXT"), "hidden column leaked into markdown:\n{md}");
assert!(!md.contains("HIDDEN_BOTH"), "hidden row+col leaked into markdown:\n{md}");

let text = document_plain(&doc);
assert!(text.contains("VISIBLE_CELL"));
assert!(!text.contains("HIDDEN_ROW_TEXT"));
assert!(!text.contains("HIDDEN_COL_TEXT"));
assert!(!text.contains("HIDDEN_BOTH"));
}

fn document_plain(doc: &Document) -> String {
use crate::model::inlines_to_plain_text;
let mut out = String::new();
for block in &doc.blocks {
if let Block::Table(t) = block {
for row in &t.grid {
for slot in row {
if let crate::model::CellSlot::Origin(cell) = slot {
for b in &cell.blocks {
if let Block::Paragraph(inlines) = b {
out.push_str(&inlines_to_plain_text(inlines));
out.push('\n');
}
}
}
}
}
}
}
out
}

#[test]
fn string_cells_are_not_trimmed() {
assert_eq!(format_data(&Data::String(" padded ".into())), " padded ");
Expand Down
Loading