Skip to content

xlsx: subtract the totals row count, not the header row count, from a table's range — and expose both - #718

Open
sjvrensburg wants to merge 1 commit into
tafia:masterfrom
sjvrensburg:xlsx-table-totals-row
Open

sjvrensburg wants to merge 1 commit into
tafia:masterfrom
sjvrensburg:xlsx-table-totals-row

Conversation

@sjvrensburg

Copy link
Copy Markdown

Disclosure: this pull request was written by an AI agent (Claude, via Claude
Code), including the code, the tests and this description. I directed and
reviewed the work, and I have run the tests and checks reported below, but I
did not hand-write the patch. I am raising it because the underlying bug is
real and reproducible, and the evidence is checkable independently of who or
what wrote it. If you do not accept AI-generated contributions, please close
this
— no hard feelings, and I would be glad to file it as an issue with the
findings instead so someone else can pick it up.

Summary

Two related problems in Xlsx::read_tables() (src/xlsx/mod.rs).

The range bug. A declared table's ref range is shifted to exclude its
header and totals rows before becoming Table::data():

let mut dims = get_dimension(table_meta.ref_cells.as_bytes())?;
if table_meta.header_row_count != 0 {
    dims.start.0 += table_meta.header_row_count;
}
if table_meta.totals_row_count != 0 {
    dims.end.0 -= table_meta.header_row_count;   // should be totals_row_count
}

The dims.end line subtracts header_row_count instead of
totals_row_count.

For the overwhelmingly common shape — one header row, and either no totals row
or one — both counts are small integers that happen to agree, so this passes
every existing test. It surfaces exactly when the two differ: a table with
no header row (headerRowCount="0", "My table has headers" unchecked in
Excel) and a totals row (totalsRowCount="1") subtracts 0 instead of 1,
leaving the fabricated totals row inside data(). The totals formula then
reads back as if it were one more row of data.

The missing accessors. Even computed correctly, neither count reached a
caller. Table<T> carried name, sheet_name, columns and data, so there
was no way to ask a table whether it actually declares a header row — only
whether columns() is non-empty, which is always true, because Excel
auto-names an unheaded table's columns (Column1, Column2, …) rather than
leaving them empty. Downstream, a headerless table's auto-generated column
names get treated as if they were the row above the table.

The fix

InnerTableMetadata's already-parsed header_row_count/totals_row_count are
threaded through Tables, TableMetadata, table_by_name and
table_by_name_ref into two new fields on Table<T>, with public accessors
Table::has_header_row() and Table::has_totals_row() (src/lib.rs)
alongside the existing name()/sheet_name()/columns()/data(). The
dims.end line now subtracts totals_row_count.

Tests

Unlike my other branches, this one has a real regression test, because XLSX
table XML is plain OOXML and can be authored.

No existing fixture has headerRowCount="0" or a nonzero totalsRowCount — I
checked every .xlsx fixture's xl/tables/*.xml directly. So
test_headerless_table_with_totals_row (tests/test.rs) writes one on the fly
with rust_xlsxwriter (added as a dev-dependency) rather than needing a
checked-in binary: a headerless three-row table with a totals row, asserting
!has_header_row(), has_totals_row(), and that data() is exactly the three
data rows.

Reverting the dims.end line to header_row_count fails it — data.height()
comes back 4, with the totals row counted as data.

If you would rather not take a new dev-dependency, say so and I will author the
fixture bytes inline instead.

Independent of #712, #713 and my other branches; branched from master.

All 272 tests pass; cargo fmt --check and cargo clippy --all-targets are
clean.

… table's range — and expose both

`Xlsx::read_tables()` shifts a declared table's `ref` range to exclude
its header and totals rows before it becomes `Table::data()`. The line
excluding the totals row subtracted `header_row_count` instead of
`totals_row_count`. For the overwhelmingly common shape — one header
row, and either no totals row or one — both counts are small integers
and no existing fixture disagreed, so this passed every test. It
surfaces exactly when the counts differ: a table with no header row
(`headerRowCount="0"`, "My table has headers" unchecked in Excel) and a
totals row (`totalsRowCount="1"`) subtracts 0 instead of 1, leaving the
fabricated totals row inside the data.

Neither count reached a caller even when computed correctly.
`Table<T>` carried `name`, `sheet_name`, `columns`, `data` and nothing
else, so there was no way to ask a table whether it actually declares a
header row — only whether `columns()` is non-empty, which is always
true: Excel auto-names an unheaded table's columns (`Column1`,
`Column2`, ...) rather than leaving them blank. A caller reading that
as "has a header" would annex whatever real content sits above the
table.

`InnerTableMetadata`'s already-parsed counts are threaded through
`Tables`, `TableMetadata`, `table_by_name` and `table_by_name_ref` onto
two new fields on `Table<T>`, with public accessors
`has_header_row()`/`has_totals_row()` alongside the existing
`name()`/`sheet_name()`/`columns()`/`data()`.

No existing fixture has `headerRowCount="0"` or a nonzero
`totalsRowCount` — checked directly against every `.xlsx` fixture's
`xl/tables/*.xml`. XLSX table XML is plain OOXML, so the regression
test authors one on the fly with `rust_xlsxwriter` (new dev-dependency)
rather than needing a checked-in fixture.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant