Skip to content
6 changes: 6 additions & 0 deletions node/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,12 @@ export interface Table {
/** Number of leading rows that are header rows (0 = no header). */
headerRows: number
kind: TableKind
/** Spreadsheet worksheet name when known. */
sheetName?: string
/** Zero-based absolute row of the used-range origin in the source sheet. */
sourceRow?: number
/** Zero-based absolute column of the used-range origin in the source sheet. */
sourceCol?: number
}

export declare const enum TableKind {
Expand Down
9 changes: 9 additions & 0 deletions node/src/document.rs
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,12 @@ pub struct Table {
/// Number of leading rows that are header rows (0 = no header).
pub header_rows: u32,
pub kind: TableKind,
/// Spreadsheet worksheet name when known.
pub sheet_name: Option<String>,
/// Zero-based absolute row of the used-range origin in the source sheet.
pub source_row: Option<u32>,
/// Zero-based absolute column of the used-range origin in the source sheet.
pub source_col: Option<u32>,
}

impl From<model::Table> for Table {
Expand All @@ -346,6 +352,9 @@ impl From<model::Table> for Table {
model::TableKind::Data => TableKind::data,
model::TableKind::Layout => TableKind::layout,
},
sheet_name: table.sheet_name,
source_row: table.source_row,
source_col: table.source_col,
}
}
}
Expand Down
9 changes: 9 additions & 0 deletions python/src/document.rs
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,12 @@ pub struct Table {
/// data (a real data table) or layout (layout scaffolding: text boxes,
/// positioning tables).
kind: &'static str,
/// Spreadsheet worksheet name when known.
sheet_name: Option<String>,
/// Zero-based absolute row of the used-range origin in the source sheet.
source_row: Option<u32>,
/// Zero-based absolute column of the used-range origin in the source sheet.
source_col: Option<u32>,
}

fn table(py: Python<'_>, table: model::Table) -> PyResult<Table> {
Expand All @@ -281,6 +287,9 @@ fn table(py: Python<'_>, table: model::Table) -> PyResult<Table> {
model::TableKind::Data => "data",
model::TableKind::Layout => "layout",
},
sheet_name: table.sheet_name,
source_row: table.source_row,
source_col: table.source_col,
})
}

Expand Down
12 changes: 11 additions & 1 deletion src/formats/odf/table.rs
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,17 @@ pub fn parse_spreadsheet(sheet: &Element, ctx: &Ctx) -> Result<Vec<Block>, Conve
if multi_sheet {
blocks.push(Block::heading(2, vec![Inline::plain(name)]));
}
blocks.extend(content);
for block in content {
match block {
Block::Table(mut t) => {
if !name.is_empty() {
t.sheet_name = Some(name.to_string());
}
blocks.push(Block::Table(t));
}
other => blocks.push(other),
}
}
}
Ok(blocks)
}
57 changes: 57 additions & 0 deletions src/formats/sheet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ pub fn parse(bytes: &[u8]) -> Result<Document, ConvertError> {
continue;
}
table.header_rows = resolve_header_rows(&table, 0);
// Preserve worksheet identity and the used-range origin so callers can
// map cropped grid cells back to absolute sheet coordinates (C3, …).
table.sheet_name = Some(name.clone());
table.source_row = Some(start.0);
table.source_col = Some(start.1);
if multi_sheet {
doc.blocks.push(Block::heading(2, vec![Inline::plain(name.clone())]));
}
Expand Down Expand Up @@ -307,4 +312,56 @@ mod tests {
assert_eq!(format_duration_days(days), "26:30:15");
assert_eq!(format_duration_days(-0.5), "-12:00:00");
}

/// Minimal xlsx: single sheet "Data Sheet", sole value at C3.
fn xlsx_value_at_c3() -> Vec<u8> {
let sheet = r#"<?xml version="1.0"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData><row r="3"><c r="C3" t="inlineStr"><is><t>Only value</t></is></c></row></sheetData></worksheet>"#;
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="Data Sheet" sheetId="1" r:id="rId1"/></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 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()
}

#[test]
fn single_sheet_exposes_name_and_used_range_origin() {
let doc = parse(&xlsx_value_at_c3()).unwrap();
let Some(Block::Table(t)) = doc.blocks.first() else {
panic!("expected a table, got {:?}", doc.blocks.first());
};
assert_eq!(t.sheet_name.as_deref(), Some("Data Sheet"));
// C3 is zero-based (2, 2); the cropped grid still starts at [0][0].
assert_eq!(t.source_row, Some(2));
assert_eq!(t.source_col, Some(2));
assert_eq!(t.grid.len(), 1);
assert_eq!(t.grid[0].len(), 1);
match &t.grid[0][0] {
crate::model::CellSlot::Origin(c) => {
assert!(!c.is_empty());
}
other => panic!("expected origin cell, got {other:?}"),
}
}
}
18 changes: 17 additions & 1 deletion src/model/table.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ pub struct Table {
pub header_rows: usize,
/// Whether the source used this table for data or for layout.
pub kind: TableKind,
/// Spreadsheet worksheet name when the table came from a workbook sheet.
/// Always set for Excel/ODS sheets (including single-sheet workbooks).
pub sheet_name: Option<String>,
/// Zero-based absolute row of the used-range origin in the source sheet
/// (e.g. Excel `C3` → `source_row = 2`). Grid index `(r, c)` maps back
/// with `(source_row + r, source_col + c)`.
pub source_row: Option<u32>,
/// Zero-based absolute column of the used-range origin in the source sheet.
pub source_col: Option<u32>,
}

/// What a table is for.
Expand Down Expand Up @@ -265,7 +274,14 @@ impl GridBuilder {
}
}
}
Table { grid: self.grid, header_rows: 0, kind }
Table {
grid: self.grid,
header_rows: 0,
kind,
sheet_name: None,
source_row: None,
source_col: None,
}
}
}

Expand Down
12 changes: 12 additions & 0 deletions wasm/src/document.rs
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,15 @@ pub struct Table {
/// Number of leading rows that are header rows (0 = no header).
pub header_rows: u32,
pub kind: TableKind,
/// Spreadsheet worksheet name when known.
#[serde(skip_serializing_if = "Option::is_none")]
pub sheet_name: Option<String>,
/// Zero-based absolute row of the used-range origin in the source sheet.
#[serde(skip_serializing_if = "Option::is_none")]
pub source_row: Option<u32>,
/// Zero-based absolute column of the used-range origin in the source sheet.
#[serde(skip_serializing_if = "Option::is_none")]
pub source_col: Option<u32>,
}

impl From<model::Table> for Table {
Expand All @@ -370,6 +379,9 @@ impl From<model::Table> for Table {
model::TableKind::Data => TableKind::Data,
model::TableKind::Layout => TableKind::Layout,
},
sheet_name: table.sheet_name,
source_row: table.source_row,
source_col: table.source_col,
}
}
}
Expand Down
6 changes: 6 additions & 0 deletions wasm/src/typescript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,12 @@ export interface Table {
/** Number of leading rows that are header rows (0 = no header). */
headerRows: number
kind: TableKind
/** Spreadsheet worksheet name when known. */
sheetName?: string
/** Zero-based absolute row of the used-range origin in the source sheet. */
sourceRow?: number
/** Zero-based absolute column of the used-range origin in the source sheet. */
sourceCol?: number
}

export type CellSlotKind = 'origin' | 'covered'
Expand Down