fix(import): honor absolute Excel worksheet row numbers

This commit is contained in:
Guoyu Su
2026-09-09 22:36:33 +08:00
committed by GitHub
parent c32be4185f
commit 7ec1d9f6e2
+243 -38
View File
@@ -2423,9 +2423,11 @@ fn parse_xlsx_preview_file_with_options(
let empty_string_as_null = options.empty_string_as_null.unwrap_or(true);
let row_range = effective_import_row_range(options)?;
let preview_limit = preview_limit.max(1);
let preview_last_row = row_range.data_start_row.saturating_add(preview_limit.saturating_sub(1));
let requested_last_row = row_range.last_data_row.map_or(preview_last_row, |last| last.min(preview_last_row));
let max_relative_row = requested_last_row.max(row_range.title_row.unwrap_or_default());
let preview_end_from = |first_row: usize| {
let preview_last_row = first_row.saturating_add(preview_limit.saturating_sub(1));
row_range.last_data_row.map_or(preview_last_row, |last| last.min(preview_last_row))
};
let mut requested_last_row = preview_end_from(row_range.data_start_row);
let mut dimension = None;
let mut raw_cells = HashMap::<(usize, usize), XlsxPreviewRawCell>::new();
@@ -2458,11 +2460,11 @@ fn parse_xlsx_preview_file_with_options(
.filter(|row| *row > 0)
.unwrap_or_else(|| current_row.saturating_add(1).max(1));
current_column = 0;
if observed_min_row != usize::MAX {
let max_absolute_row = observed_min_row.saturating_add(max_relative_row.saturating_sub(1));
if current_row > max_absolute_row {
break;
}
if row_range.last_data_row.is_some_and(|last| current_row > last)
|| (observed_min_row != usize::MAX
&& current_row > requested_last_row.max(row_range.title_row.unwrap_or_default()))
{
break;
}
observed_max_row = observed_max_row.max(current_row);
}
@@ -2474,6 +2476,7 @@ fn parse_xlsx_preview_file_with_options(
current_row = position.0;
current_column = position.1;
observed_min_row = observed_min_row.min(position.0);
requested_last_row = preview_end_from(row_range.data_start_row.max(observed_min_row));
observed_min_column = observed_min_column.min(position.1);
observed_max_row = observed_max_row.max(position.0);
observed_max_column = observed_max_column.max(position.1);
@@ -2528,12 +2531,12 @@ fn parse_xlsx_preview_file_with_options(
Ok(Event::End(element)) if xml_local_name_eq(element.name().as_ref(), b"c") => {
if let Some((row, column)) = current_position.take() {
observed_min_row = observed_min_row.min(row);
requested_last_row = preview_end_from(row_range.data_start_row.max(observed_min_row));
observed_min_column = observed_min_column.min(column);
observed_max_column = observed_max_column.max(column);
observed_max_row = observed_max_row.max(row);
let relative_row = row.saturating_sub(observed_min_row).saturating_add(1);
if relative_row == row_range.title_row.unwrap_or_default()
|| (relative_row >= row_range.data_start_row && relative_row <= requested_last_row)
if row == row_range.title_row.unwrap_or_default()
|| (row >= row_range.data_start_row && row <= requested_last_row)
{
raw_cells.insert((row, column), std::mem::take(&mut current_cell));
}
@@ -2563,7 +2566,7 @@ fn parse_xlsx_preview_file_with_options(
let observed_end_column = observed_max_column.max(start_column);
let observed_column_count = observed_end_column.saturating_sub(start_column).saturating_add(1);
let preview_row_count = requested_last_row
.saturating_sub(row_range.data_start_row)
.saturating_sub(row_range.data_start_row.max(start_row))
.saturating_add(1)
.saturating_add(usize::from(row_range.title_row.is_some()));
if observed_column_count.saturating_mul(preview_row_count) > MAX_FAST_PREVIEW_CELLS {
@@ -2584,11 +2587,10 @@ fn parse_xlsx_preview_file_with_options(
let end_column = dimension_end_column.unwrap_or(observed_end_column).max(observed_end_column);
let column_count = end_column.saturating_sub(start_column).saturating_add(1);
let mut columns = if let Some(title_row) = row_range.title_row {
let absolute_title_row = start_row.saturating_add(title_row.saturating_sub(1));
unique_import_headers((0..column_count).map(|index| {
let column = start_column + index;
let value = raw_cells
.get(&(absolute_title_row, column))
.get(&(title_row, column))
.map(|cell| xlsx_preview_cell_value(cell, &shared_strings, &styles, date_1904, empty_string_as_null))
.unwrap_or(serde_json::Value::Null);
normalize_header(&xlsx_preview_cell_label(&value), index)
@@ -2603,14 +2605,13 @@ fn parse_xlsx_preview_file_with_options(
return Err("Import file has no columns in the selected row range".to_string());
}
let observed_end_relative = observed_max_row.saturating_sub(start_row).saturating_add(1);
let last_preview_row = requested_last_row.min(observed_end_relative);
if last_preview_row < row_range.data_start_row {
let first_preview_row = row_range.data_start_row.max(start_row);
let last_preview_row = requested_last_row.min(observed_max_row);
if last_preview_row < first_preview_row {
return Err("Import file has no data rows in the selected row range".to_string());
}
let rows = (row_range.data_start_row..=last_preview_row)
.map(|relative_row| {
let absolute_row = start_row + relative_row - 1;
let rows = (first_preview_row..=last_preview_row)
.map(|absolute_row| {
(0..columns.len())
.map(|index| {
raw_cells
@@ -2663,8 +2664,9 @@ where
T: CellType,
Label: Fn(&T, Option<XlsxTemporalKind>) -> String,
{
let range_start_row = range.start().map_or(0, |(row, _)| row as usize);
for (index, source_row) in range.rows().enumerate() {
let row_number = index + 1;
let row_number = range_start_row + index + 1;
if row_range.title_row == Some(row_number) {
return unique_import_headers(
source_row.iter().enumerate().map(|(index, cell)| normalize_header(&cell_label(cell, None), index)),
@@ -2886,10 +2888,7 @@ impl XlsxStreamRowsState {
}
fn selected_range_finished(&self, absolute_row: usize) -> bool {
let Some(start_row) = self.start_row else {
return false;
};
self.row_range.last_data_row.is_some_and(|last| absolute_row > start_row.saturating_add(last.saturating_sub(1)))
self.row_range.last_data_row.is_some_and(|last| absolute_row > last)
}
fn is_text_source_column(
@@ -2944,8 +2943,7 @@ impl XlsxStreamRowsState {
mut values: Vec<serde_json::Value>,
progress: u64,
) -> Result<(), String> {
let relative_row = absolute_row.saturating_sub(self.start_row.unwrap_or(absolute_row)).saturating_add(1);
if self.row_range.title_row == Some(relative_row) {
if self.row_range.title_row == Some(absolute_row) {
if self.columns.is_empty() {
let column_count = self.declared_column_count.unwrap_or(values.len()).max(values.len());
values.resize(column_count, serde_json::Value::Null);
@@ -2958,8 +2956,8 @@ impl XlsxStreamRowsState {
}
return Ok(());
}
if relative_row < self.row_range.data_start_row
|| self.row_range.last_data_row.is_some_and(|last| relative_row > last)
if absolute_row < self.row_range.data_start_row
|| self.row_range.last_data_row.is_some_and(|last| absolute_row > last)
{
return Ok(());
}
@@ -3337,11 +3335,11 @@ where
let mut rows = Vec::new();
let mut total_rows = 0;
for (index, source_row) in range.rows().enumerate() {
let row_number = index + 1;
// Row options and XLSX style coordinates are worksheet-absolute; Calamine indices are range-relative.
let row_number = range_start_row + index + 1;
if row_range.title_row == Some(row_number) {
columns = unique_import_headers(source_row.iter().enumerate().map(|(index, cell)| {
// Calamine rows are relative to the used range, while XLSX style coordinates are worksheet-absolute.
let cell_position = (range_start_row + row_number, range_start_column + index + 1);
let cell_position = (row_number, range_start_column + index + 1);
normalize_header(
&cell_label(cell, cell_styles.get(&cell_position).and_then(|style| style.temporal_kind)),
index,
@@ -3364,7 +3362,7 @@ where
}
let mut row = Vec::with_capacity(columns.len());
for (index, column) in columns.iter().enumerate() {
let cell_position = (range_start_row + row_number, range_start_column + index + 1);
let cell_position = (row_number, range_start_column + index + 1);
let style = cell_styles.get(&cell_position);
let value = source_row
.get(index)
@@ -7407,6 +7405,211 @@ mod tests {
zip.finish().unwrap().into_inner()
}
fn assert_xlsx_absolute_row_selection(path_kind: &str) {
for origin in [1, 3, 7] {
for first_column in *b"AC" {
let last_column = char::from(first_column + 3);
let first_column_label = char::from(first_column);
let mut rows_xml = format!(
r#"<row r="{origin}"><c r="{first_column_label}{origin}" t="inlineStr"><is><t>report</t></is></c></row><row r="8">"#
);
for index in 0..4 {
let column = char::from(first_column + index);
let label = index + 1;
rows_xml.push_str(&format!(r#"<c r="{column}8" t="inlineStr"><is><t>列{label}</t></is></c>"#));
}
rows_xml.push_str("</row>");
for row in 9..=12 {
rows_xml.push_str(&format!(r#"<row r="{row}">"#));
for index in 0..4 {
let column = char::from(first_column + index);
let value = row - 8;
rows_xml.push_str(&format!(r#"<c r="{column}{row}"><v>{value}</v></c>"#));
}
rows_xml.push_str("</row>");
}
let sheet_xml = format!(
r#"<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><dimension ref="{first_column_label}{origin}:{last_column}12"/><sheetData>{rows_xml}</sheetData></worksheet>"#
);
let path = std::env::temp_dir().join(format!("dbx-absolute-rows-{}.xlsx", uuid::Uuid::new_v4()));
std::fs::write(&path, build_preview_test_xlsx(&sheet_xml, None)).unwrap();
for title_row in [8, 0] {
for last_data_row in [0, 10] {
let options = TableImportParseOptions {
title_row: Some(title_row),
data_start_row: Some(9),
last_data_row: Some(last_data_row),
..TableImportParseOptions::default()
};
let (columns, rows) = match path_kind {
"preview" => {
let (parsed, _) =
parse_xlsx_preview_file_with_options(&path.to_string_lossy(), &options, 50)
.unwrap();
(parsed.columns, parsed.rows)
}
"parse" => {
let parsed =
parse_xlsx_file_with_options(&path.to_string_lossy(), &options, 50).unwrap();
assert_eq!(parsed.total_rows, if last_data_row == 0 { 4 } else { 2 });
(parsed.columns, parsed.rows)
}
"stream" => {
let (sender, mut receiver) = tokio::sync::mpsc::channel(16);
stream_xlsx_rows_to_channel(
&path.to_string_lossy(),
&options,
500,
None,
HashSet::new(),
false,
sender,
)
.unwrap();
let mut columns = Vec::new();
let mut rows = Vec::new();
while let Some(message) = receiver.blocking_recv() {
match message.unwrap() {
XlsxStreamMessage::Header(header) => columns = header,
XlsxStreamMessage::Rows(batch) => rows.extend(batch),
_ => {}
}
}
(columns, rows)
}
_ => unreachable!(),
};
let expected_columns = (1..=4)
.map(|index| if title_row == 0 { format!("column_{index}") } else { format!("列{index}") })
.collect::<Vec<_>>();
let expected_rows = (1..=if last_data_row == 0 { 4 } else { 2 })
.map(|value| vec![serde_json::json!(value); 4])
.collect::<Vec<_>>();
assert_eq!(
columns, expected_columns,
"{path_kind}, origin {origin}, title {title_row}, last {last_data_row}"
);
assert_eq!(
rows, expected_rows,
"{path_kind}, origin {origin}, title {title_row}, last {last_data_row}"
);
}
}
let _ = std::fs::remove_file(path);
}
}
}
#[test]
fn xlsx_absolute_rows_preview() {
assert_xlsx_absolute_row_selection("preview");
}
#[test]
fn xlsx_absolute_rows_full_parse() {
assert_xlsx_absolute_row_selection("parse");
}
#[test]
fn xlsx_absolute_rows_streaming() {
assert_xlsx_absolute_row_selection("stream");
}
#[test]
fn xlsx_absolute_rows_preview_limit_excludes_leading_unused_rows() {
let path = std::env::temp_dir().join(format!("dbx-leading-unused-rows-{}.xlsx", uuid::Uuid::new_v4()));
std::fs::write(&path, build_styled_test_xlsx(false, &[("C100", 0, 5.0), ("C101", 0, 6.0)])).unwrap();
let options = TableImportParseOptions { title_row: Some(0), ..TableImportParseOptions::default() };
let parsed = parse_xlsx_file_with_options(&path.to_string_lossy(), &options, 1).unwrap();
let (preview, _) = parse_xlsx_preview_file_with_options(&path.to_string_lossy(), &options, 1).unwrap();
assert_eq!(preview.columns, vec!["column_1"]);
assert_eq!(preview.rows, vec![vec![serde_json::json!(5)]]);
assert_eq!(preview.rows, parsed.rows);
assert_eq!(parsed.total_rows, 2);
let limited = TableImportParseOptions { last_data_row: Some(99), ..options };
assert!(parse_xlsx_preview_file_with_options(&path.to_string_lossy(), &limited, 1).is_err());
assert!(parse_xlsx_file_with_options(&path.to_string_lossy(), &limited, 1).is_err());
let _ = std::fs::remove_file(path);
}
#[test]
fn xlsx_absolute_rows_preserve_header_and_value_styles() {
let path = std::env::temp_dir().join(format!("dbx-absolute-row-styles-{}.xlsx", uuid::Uuid::new_v4()));
std::fs::write(
&path,
build_styled_test_xlsx(
false,
&[
("C7", 0, -1.0),
("C8", 0, 10.0),
("D8", 2, 45996.0),
("E8", 0, 42.0),
("C9", 5, 20.0),
("D9", 1, 45996.0),
("E9", 0, 1.5),
("C10", 0, 99.0),
],
),
)
.unwrap();
let options = TableImportParseOptions {
title_row: Some(8),
data_start_row: Some(9),
last_data_row: Some(9),
..TableImportParseOptions::default()
};
let text_columns = HashSet::from(["10".to_string()]);
let parsed =
parse_xlsx_file_with_options_and_text_columns(&path.to_string_lossy(), &options, 50, &text_columns)
.unwrap();
let (preview, _) = parse_xlsx_preview_file_with_options(&path.to_string_lossy(), &options, 50).unwrap();
assert_eq!(parsed.columns, vec!["10", "2025-12-05 00:00:00", "42"]);
assert_eq!(preview.columns, parsed.columns);
assert_eq!(
parsed.rows,
vec![vec![serde_json::json!("20.0"), serde_json::json!("2025-12-05"), serde_json::json!(1.5)]]
);
assert_eq!(
preview.rows,
vec![vec![serde_json::json!(20), serde_json::json!("2025-12-05"), serde_json::json!(1.5)]]
);
let (sender, mut receiver) = tokio::sync::mpsc::channel(16);
stream_xlsx_rows_to_channel(
&path.to_string_lossy(),
&options,
500,
Some(preview.columns),
text_columns,
false,
sender,
)
.unwrap();
let mut streamed_rows = Vec::new();
while let Some(message) = receiver.blocking_recv() {
if let XlsxStreamMessage::Rows(rows) = message.unwrap() {
streamed_rows.extend(rows);
}
}
assert_eq!(streamed_rows, parsed.rows);
let _ = std::fs::remove_file(path);
}
#[test]
fn xlsx_absolute_rows_streaming_stops_at_worksheet_last_row() {
let options = TableImportParseOptions {
title_row: Some(8),
data_start_row: Some(9),
last_data_row: Some(10),
..TableImportParseOptions::default()
};
let (sender, _receiver) = tokio::sync::mpsc::channel(16);
let mut state =
XlsxStreamRowsState::new(sender, effective_import_row_range(&options).unwrap(), None, None, 500);
state.initialize_range(7, 3);
assert!(!state.selected_range_finished(10));
assert!(state.selected_range_finished(11));
}
fn assert_xlsx_empty_string_option(options: TableImportParseOptions, expected_row: Vec<serde_json::Value>) {
let path =
std::env::temp_dir().join(format!("dbx-table-import-empty-string-option-{}.xlsx", uuid::Uuid::new_v4()));
@@ -9249,11 +9452,13 @@ mod tests {
</worksheet>"#;
std::fs::write(&path, build_preview_test_xlsx(sheet_xml, None)).unwrap();
let parsed =
parse_xlsx_file_with_options(&path.to_string_lossy(), &TableImportParseOptions::default(), 10).unwrap();
let (preview, _) =
parse_xlsx_preview_file_with_options(&path.to_string_lossy(), &TableImportParseOptions::default(), 10)
.unwrap();
let options = TableImportParseOptions {
title_row: Some(100),
data_start_row: Some(101),
..TableImportParseOptions::default()
};
let parsed = parse_xlsx_file_with_options(&path.to_string_lossy(), &options, 10).unwrap();
let (preview, _) = parse_xlsx_preview_file_with_options(&path.to_string_lossy(), &options, 10).unwrap();
assert_eq!(preview.columns, parsed.columns);
assert_eq!(preview.rows, parsed.rows);