From 773c9accf65f861eb75712905b8310a3936733be Mon Sep 17 00:00:00 2001 From: Dongcheng Lin Date: Sat, 24 Oct 2026 22:07:49 +0900 Subject: [PATCH] feat(import): split source data worksheets into tables --- crates/app/src/ui/file_dialogs/xlsx.rs | 6 +- crates/core/src/automation/table_import.rs | 40 +- .../core/src/automation/table_import_xlsx.rs | 182 +++-- .../src/automation/table_import_xlsx_tests.rs | 29 + crates/core/src/xlsx.rs | 128 +++- crates/core/src/xlsx/layout.rs | 719 ++++++++++++++++++ crates/core/src/xlsx/layout_tests.rs | 312 ++++++++ crates/core/src/xlsx/regions.rs | 154 ++++ crates/core/src/xlsx/tests.rs | 1 + crates/io/src/xlsx.rs | 10 + .../src/content/docs/guides/importing-data.md | 30 +- .../content/docs/guides/supported-formats.md | 2 +- docs/src/content/docs/reference/mcp/tables.md | 16 +- .../docs/zh-cn/guides/importing-data.md | 21 +- .../docs/zh-cn/guides/supported-formats.md | 2 +- .../docs/zh-cn/reference/mcp/tables.md | 9 +- 16 files changed, 1555 insertions(+), 106 deletions(-) create mode 100644 crates/core/src/xlsx/layout.rs create mode 100644 crates/core/src/xlsx/layout_tests.rs create mode 100644 crates/core/src/xlsx/regions.rs diff --git a/crates/app/src/ui/file_dialogs/xlsx.rs b/crates/app/src/ui/file_dialogs/xlsx.rs index 09484f24..a0e97347 100644 --- a/crates/app/src/ui/file_dialogs/xlsx.rs +++ b/crates/app/src/ui/file_dialogs/xlsx.rs @@ -100,7 +100,7 @@ pub(super) fn import_xlsx_table_path(app: &mut PlotiumApp, path: &std::path::Pat .map(str::to_owned); source.metadata.insert( "space.nmrtist.plotium.import.worksheet".into(), - serde_json::Value::String(sheet.name.clone()), + serde_json::Value::String(sheet.worksheet.clone()), ); source.metadata.insert( "space.nmrtist.plotium.import.formula_policy".into(), @@ -129,7 +129,7 @@ pub(super) fn import_xlsx_table_path(app: &mut PlotiumApp, path: &std::path::Pat ) .with_source("core.xlsx") .with_context("path", path.display().to_string()) - .with_context("worksheet", sheet.name.clone()), + .with_context("worksheet", sheet.worksheet.clone()), ); for diagnostic in sheet.diagnostics { report = report.with_diagnostic( @@ -140,7 +140,7 @@ pub(super) fn import_xlsx_table_path(app: &mut PlotiumApp, path: &std::path::Pat ) .with_source("core.xlsx") .with_context("path", path.display().to_string()) - .with_context("worksheet", sheet.name.clone()), + .with_context("worksheet", sheet.worksheet.clone()), ); } } diff --git a/crates/core/src/automation/table_import.rs b/crates/core/src/automation/table_import.rs index 8dfcaae3..d4a9bb9a 100644 --- a/crates/core/src/automation/table_import.rs +++ b/crates/core/src/automation/table_import.rs @@ -52,8 +52,11 @@ pub struct TableImportParams { #[serde(default)] pub header: ImportHeader, pub format: Option, - /// XLSX worksheets to import by name; every visible worksheet when absent. + /// XLSX worksheets to import by name; every visible worksheet when + /// neither this nor `regions` is given. pub sheets: Option>, + /// Explicit XLSX ranges, each imported as one table. + pub regions: Option>, /// An explicit ordered schema, not display-name-based resource addressing. pub schema: Option, pub schema_path: Option, @@ -65,7 +68,12 @@ pub fn table_import_schema() -> Value { json!({"type":"object","additionalProperties":false,"required":["mode","paths"],"properties":{ "mode":{"const":"table_data_only"},"paths":{"type":"array","minItems":1,"maxItems":TABLE_IMPORT_FILES,"items":{"type":"string"}}, "header":{"enum":["auto","present","absent"],"default":"auto","description":"CSV/TSV only."},"format":{"enum":["csv","tsv","xlsx"]}, - "sheets":{"type":"array","minItems":1,"items":{"type":"string"},"description":"XLSX worksheet names, each imported as its own table. Default: every visible worksheet."}, +"sheets":{"type":"array","minItems":1,"items":{"type":"string"},"description":"XLSX worksheets whose tables are recognized automatically. Default: every visible worksheet, unless regions is given."}, + "regions":{"type":"array","minItems":1,"items":{"type":"object","additionalProperties":false,"required":["sheet","range"],"properties":{ + "sheet":{"type":"string"},"range":{"type":"string","description":"A1 range such as J2:AC4411, or columns such as J:AC; include the header rows."}, + "header_rows":{"type":"integer","minimum":0,"description":"Header rows at the top of range. Default: rows above the first value."}, + "name":{"type":"string"},"split_segments":{"type":"boolean","default":true,"description":"Split a column where text rows separate stacked runs of values."}}}, + "description":"XLSX ranges imported as one table each, for tables recognized wrongly; each preflight item reports its sheet and range."}, "schema":table_declaration_schema(), "schema_path":{"type":"string","description":"Explicit PlotiumDelimitedSchemaV1 JSON file for CSV/TSV. Never discovered automatically."}}, "not":{"required":["schema","schema_path"]}}) @@ -106,6 +114,9 @@ impl TableImportParams { if self.sheets.as_ref().is_some_and(Vec::is_empty) { return Err(invalid("sheets must name at least one worksheet.")); } + if self.regions.as_ref().is_some_and(Vec::is_empty) { + return Err(invalid("regions must list at least one range.")); + } if self.schema.is_some() && self.schema_path.is_some() { return Err(invalid("Supply schema or schema_path, not both.")); } @@ -126,7 +137,7 @@ impl TableImportParams { struct ImportItem { path: PathBuf, /// The source worksheet of an XLSX table. - sheet: Option, + sheet: Option, dataset: Option, summary: Value, error: Option, @@ -136,7 +147,10 @@ impl ImportItem { let mut value = json!({"path":self.path,"status":status,"metadata":self.summary,"error":self.error}); if let Some(sheet) = &self.sheet { - value["sheet"] = json!(sheet); + value["sheet"] = json!(sheet.worksheet); + if let Some(range) = &sheet.range { + value["range"] = json!(range); + } } value } @@ -216,7 +230,7 @@ impl PreparedTableImport { let mut workbooks: Vec<(&Path, Vec)> = Vec::new(); for item in &prepared.items { let Some(sheet) = &item.sheet else { continue }; - let name = format!("'{sheet}'"); + let name = format!("'{}'", sheet.worksheet); match workbooks.iter_mut().find(|(path, _)| *path == item.path) { Some((_, names)) => names.push(name), None => workbooks.push((&item.path, vec![name])), @@ -300,7 +314,10 @@ impl PreparedTableImport { TargetOutcome::Failed }, message: item.error.clone().unwrap_or_else(|| match &item.sheet { - Some(sheet) => format!("Imported worksheet '{sheet}' as native table data."), + Some(sheet) => format!( + "Imported worksheet '{}' as native table data.", + sheet.worksheet + ), None => "Imported native table data.".into(), }), skip_reason: None, @@ -400,10 +417,15 @@ fn prepare_file( ); } let bytes = read_selected(path, budget, TABLE_IMPORT_FILE_BYTES)?; - return super::table_import_xlsx::prepare(path, bytes, params.sheets.as_deref()); + return super::table_import_xlsx::prepare( + path, + bytes, + params.sheets.as_deref(), + params.regions.as_deref(), + ); } - if params.sheets.is_some() { - return Err("sheets applies to XLSX workbooks only.".into()); + if params.sheets.is_some() || params.regions.is_some() { + return Err("sheets and regions apply to XLSX workbooks only.".into()); } let bytes = read_selected(path, budget, TABLE_IMPORT_FILE_BYTES)?; let input = std::str::from_utf8(&bytes).map_err(|e| format!("CSV/TSV must be UTF-8: {e}"))?; diff --git a/crates/core/src/automation/table_import_xlsx.rs b/crates/core/src/automation/table_import_xlsx.rs index 1eca778e..429c49b2 100644 --- a/crates/core/src/automation/table_import_xlsx.rs +++ b/crates/core/src/automation/table_import_xlsx.rs @@ -1,8 +1,9 @@ -//! XLSX workbooks import one native table per worksheet, as the desktop does. +//! XLSX workbooks import one native table per recognized worksheet table, as +//! the desktop does, plus any explicitly named regions. use super::table_import::{TABLE_IMPORT_COLUMNS, TABLE_IMPORT_ROWS, TABLE_IMPORT_XLSX_CELLS}; use crate::{ state::{Dataset, TableDataset, TableImportSource, TypedTableState}, - xlsx::ImportedXlsxSheet, + xlsx::{ImportedXlsxSheet, XlsxRegion}, }; use plotium_data::{CodecRegistry, MemoryBlockStore}; use plotium_io::xlsx::{XlsxIoError, XlsxReader, XlsxWorkbook}; @@ -10,17 +11,25 @@ use std::{path::Path, sync::Arc}; const XLSX_MEDIA_TYPE: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; +/// The worksheet, and range within it, that an XLSX table was read from. +#[derive(Clone, Debug)] +pub(super) struct SourceSheet { + pub worksheet: String, + pub range: Option, +} + /// A prepared table, or why it failed, with the worksheet it came from. -pub(super) type PreparedTable = (Option, Result); +pub(super) type PreparedTable = (Option, Result); -/// Parses the retained bytes worksheet by worksheet, so one worksheet that -/// fails or exceeds the cell budget leaves the others importable. `sheets` -/// selects worksheets by name; otherwise every visible worksheet with cells is -/// imported. +/// Parses the retained bytes worksheet by worksheet, so one worksheet or +/// region that fails or exceeds the cell budget leaves the others importable. +/// `sheets` selects worksheets for recognition and `regions` adds explicit +/// ranges; without either, every visible worksheet with cells is recognized. pub(super) fn prepare( path: &Path, bytes: Vec, sheets: Option<&[String]>, + regions: Option<&[XlsxRegion]>, ) -> Result, String> { let bytes = Arc::<[u8]>::from(bytes); let mut reader = @@ -31,85 +40,138 @@ pub(super) fn prepare( .filter(|sheet| !sheet.hidden) .map(|sheet| sheet.name.clone()) .collect::>(); - let selected = match sheets { - Some(names) => { - if let Some(missing) = names.iter().find(|name| !visible.contains(name)) { - let available = visible - .iter() - .map(|name| format!("'{name}'")) - .collect::>() - .join(", "); - return Err(format!( - "Worksheet '{missing}' is not a visible worksheet of this workbook. Visible worksheets: {available}." - )); - } - visible + let named = sheets + .into_iter() + .flatten() + .chain(regions.into_iter().flatten().map(|region| ®ion.sheet)); + for name in named { + if !visible.contains(name) { + let available = visible .iter() - .filter(|name| names.contains(name)) - .cloned() - .collect() + .map(|name| format!("'{name}'")) + .collect::>() + .join(", "); + return Err(format!( + "Worksheet '{name}' is not a visible worksheet of this workbook. Visible worksheets: {available}." + )); } - None => visible, + } + let recognized = |name: &String| match (sheets, regions) { + (Some(names), _) => names.contains(name), + (None, Some(_)) => false, + (None, None) => true, }; + let in_region = + |name: &String| regions.is_some_and(|regions| regions.iter().any(|r| r.sheet == *name)); let mut remaining = TABLE_IMPORT_XLSX_CELLS; let mut read = Vec::new(); - for name in selected { - match reader.read_sheet(&name, remaining) { + for name in visible + .iter() + .filter(|name| recognized(name) || in_region(name)) + { + let sheet = match reader.read_sheet(name, remaining) { Ok(sheet) => { let width = sheet.rows.iter().map(Vec::len).max().unwrap_or(0); remaining = remaining.saturating_sub(sheet.rows.len() * width); // Workbooks often keep blank default sheets, which hold no table. - if sheets.is_some() || !sheet.rows.is_empty() { - read.push((name, Ok(sheet))); + if sheets.is_none() && regions.is_none() && sheet.rows.is_empty() { + continue; } + Ok(sheet) } - Err(XlsxIoError::SheetOverBudget { .. }) => read.push(( - name, - Err(format!( - "The worksheet exceeds the cell budget: one XLSX import reads at most {TABLE_IMPORT_XLSX_CELLS} cells across its worksheets. Name fewer worksheets in sheets." - )), + Err(XlsxIoError::SheetOverBudget { .. }) => Err(format!( + "The worksheet exceeds the cell budget: one XLSX import reads at most {TABLE_IMPORT_XLSX_CELLS} cells across its worksheets. Name fewer worksheets in sheets." )), - Err(error) => read.push((name, Err(error.to_string()))), - } + Err(error) => Err(error.to_string()), + }; + read.push((name.clone(), sheet)); } if read.is_empty() { return Err("The workbook has no visible worksheet with cells.".into()); } + // Each worksheet is imported as a workbook of its own so it fails alone; + // the schema sidecar and cell diagnostics are shared. let shared = reader.into_workbook(Vec::new()); - let file_stem = (sheets.is_none() && read.len() == 1).then(|| { - path.file_stem() - .unwrap_or_default() - .to_string_lossy() - .into_owned() - }); + let read = read + .into_iter() + .map(|(name, sheet)| { + let workbook = sheet.map(|sheet| XlsxWorkbook { + sheets: vec![sheet], + ..shared.clone() + }); + (name, workbook) + }) + .collect::>(); let retained = Retained { bytes, file_name: path.file_name().map(|s| s.to_string_lossy().into_owned()), store: Arc::new(MemoryBlockStore::default()), }; let codecs = CodecRegistry::with_arrow_ipc(); - let mut prepared = Vec::new(); - for (name, sheet) in read { - let tables = sheet.and_then(|sheet| { - let workbook = XlsxWorkbook { - sheets: vec![sheet], - ..shared.clone() - }; - crate::xlsx::import_xlsx_workbook(&workbook, retained.store.as_ref(), &codecs) + let mut tables: Vec<(SourceSheet, Result)> = Vec::new(); + for (name, sheet) in read.iter().filter(|(name, _)| recognized(name)) { + let imported = sheet.as_ref().map_err(Clone::clone).and_then(|workbook| { + crate::xlsx::import_xlsx_workbook(workbook, retained.store.as_ref(), &codecs) .map_err(|e| e.to_string()) }); - match tables { - Ok(tables) => prepared.extend(tables.into_iter().map(|table| { - let dataset_name = file_stem.clone().unwrap_or_else(|| table.name.clone()); - ( - Some(table.name.clone()), - retained.dataset(table, dataset_name), - ) + match imported { + Ok(imported) => tables.extend(imported.into_iter().map(|table| { + let source = SourceSheet { + worksheet: table.worksheet.clone(), + range: table.range.clone(), + }; + (source, Ok(table)) })), - Err(error) => prepared.push((Some(name), Err(error))), + Err(error) => tables.push(( + SourceSheet { + worksheet: name.clone(), + range: None, + }, + Err(error), + )), } } - Ok(prepared) + for region in regions.into_iter().flatten() { + let sheet = read + .iter() + .find(|(name, _)| *name == region.sheet) + .map(|(_, sheet)| sheet) + .ok_or("The region's worksheet was not read.")?; + let imported = sheet.as_ref().map_err(Clone::clone).and_then(|workbook| { + crate::xlsx::import_xlsx_regions( + workbook, + std::slice::from_ref(region), + retained.store.as_ref(), + &codecs, + ) + .map_err(|e| e.to_string())? + .pop() + .ok_or_else(|| "The region produced no table.".to_owned()) + }); + let source = SourceSheet { + worksheet: region.sheet.clone(), + range: imported + .as_ref() + .map_or(Some(region.range.clone()), |table| table.range.clone()), + }; + tables.push((source, imported)); + } + let file_stem = (sheets.is_none() && regions.is_none() && tables.len() == 1).then(|| { + path.file_stem() + .unwrap_or_default() + .to_string_lossy() + .into_owned() + }); + Ok(tables + .into_iter() + .map(|(source, table)| { + let dataset = table.and_then(|table| { + let name = file_stem.clone().unwrap_or_else(|| table.name.clone()); + retained.dataset(table, name) + }); + (Some(source), dataset) + }) + .collect()) } /// What every table of one workbook shares. @@ -125,7 +187,7 @@ impl Retained { || sheet.snapshot.row_count > TABLE_IMPORT_ROWS as u64 { return Err(format!( - "The worksheet exceeds the budget of {TABLE_IMPORT_COLUMNS} columns and {TABLE_IMPORT_ROWS} rows per table." + "The table exceeds the budget of {TABLE_IMPORT_COLUMNS} columns and {TABLE_IMPORT_ROWS} rows per table; import a narrower range with regions." )); } let typed = TypedTableState::imported_with_operation( @@ -140,7 +202,7 @@ impl Retained { source.name = self.file_name.clone(); source.metadata.insert( "space.nmrtist.plotium.import.worksheet".into(), - serde_json::Value::String(sheet.name), + serde_json::Value::String(sheet.worksheet), ); source.metadata.insert( "space.nmrtist.plotium.import.formula_policy".into(), diff --git a/crates/core/src/automation/table_import_xlsx_tests.rs b/crates/core/src/automation/table_import_xlsx_tests.rs index 484182e3..e9262744 100644 --- a/crates/core/src/automation/table_import_xlsx_tests.rs +++ b/crates/core/src/automation/table_import_xlsx_tests.rs @@ -16,6 +16,7 @@ fn sheet(name: &str, rows: Vec>) -> XlsxSheet { XlsxSheet { name: name.into(), hidden: false, + origin: (0, 0), rows, } } @@ -236,3 +237,31 @@ fn xlsx_rejects_a_declared_schema() { "{error}" ); } + +#[test] +fn xlsx_regions_import_only_the_named_ranges_and_fail_alone() { + let path = workbook(vec![curve("Figure 1", 1.0), curve("Figure 2", 5.0)]); + let regions = json!([ + {"sheet":"Figure 2","range":"B1:B3","name":"Current"}, + {"sheet":"Figure 2","range":"F1:G3"} + ]); + let preflight = + prepare(json!({"mode":"table_data_only","paths":[path],"regions":regions})).preflight(); + let items = preflight["items"].as_array().unwrap(); + assert_eq!(items.len(), 2, "{preflight}"); + assert_eq!(items[0]["sheet"], "Figure 2"); + assert_eq!(items[0]["range"], "B1:B3"); + assert_eq!(items[0]["metadata"]["columns"][0]["name"], "Current (mA)"); + assert_eq!(items[1]["status"], "failed"); + assert_eq!(items[1]["range"], "F1:G3"); + + let unknown = prepare( + json!({"mode":"table_data_only","paths":[path],"regions":[{"sheet":"Figure 9","range":"A:B"}]}), + ); + std::fs::remove_file(&path).unwrap(); + let error = unknown.preflight()["items"][0]["error"] + .as_str() + .unwrap() + .to_owned(); + assert!(error.contains("'Figure 9'"), "{error}"); +} diff --git a/crates/core/src/xlsx.rs b/crates/core/src/xlsx.rs index 7cda2cf9..dd03c1f3 100644 --- a/crates/core/src/xlsx.rs +++ b/crates/core/src/xlsx.rs @@ -7,6 +7,10 @@ use plotium_data::{ use plotium_io::xlsx::{XlsxCell, XlsxDiagnostic, XlsxSheet, XlsxValue, XlsxWorkbook}; use serde::{Deserialize, Serialize}; +mod layout; +mod regions; +pub use regions::{XlsxRegion, import_xlsx_regions}; + const NANOS_PER_SECOND: i64 = 1_000_000_000; const NANOS_PER_DAY: i64 = 86_400 * NANOS_PER_SECOND; @@ -16,10 +20,17 @@ pub enum XlsxImportError { Data(#[from] plotium_data::DataError), #[error("worksheet '{0}' has no tabular cells")] EmptySheet(String), + #[error("{0}")] + Region(String), } pub struct ImportedXlsxSheet { + /// The table name: the worksheet name, extended by the panel letter, title + /// or range when one worksheet holds several tables. pub name: String, + pub worksheet: String, + /// The A1 range the table was read from when it is not the whole worksheet. + pub range: Option, pub snapshot: TableSnapshot, pub diagnostics: Vec, } @@ -97,6 +108,7 @@ pub fn import_delimited_with_schema( sheets: vec![XlsxSheet { name: "Data".into(), hidden: false, + origin: (0, 0), rows, }], uses_1904_date_system: false, @@ -151,36 +163,95 @@ pub fn import_xlsx_workbook( .collect::>(); let mut imported = Vec::new(); for sheet in workbook.sheets.iter().filter(|sheet| !sheet.hidden) { - let mut diagnostics = workbook_diagnostics - .iter() - .filter(|diagnostic| diagnostic.starts_with(&format!("{}!", sheet.name))) - .cloned() - .collect::>(); - let (headers, data_rows) = split_header(sheet, &mut diagnostics)?; + let mut diagnostics = sheet_diagnostics(&workbook_diagnostics, sheet); let sidecar_sheet = sidecar .as_ref() .and_then(|schema| schema.sheets.iter().find(|entry| entry.name == sheet.name)); - let (table_id, schema, uncertainty) = - resolve_schema(&headers, data_rows, sidecar_sheet, &mut diagnostics)?; - let snapshot = build_snapshot( - table_id, - schema, - uncertainty, - sheet, - data_rows, - &diagnostics, - store, - codecs, - )?; - imported.push(ImportedXlsxSheet { - name: sheet.name.clone(), - snapshot, - diagnostics, - }); + // Plotium's own exports keep their declared schema; sheets without + // values keep the sheet-level reading. + let tables = match sidecar_sheet { + Some(_) => Vec::new(), + None => layout::auto_tables(sheet), + }; + if tables.is_empty() { + let (headers, data_rows) = split_header(sheet, &mut diagnostics)?; + let (table_id, schema, uncertainty) = + resolve_schema(&headers, data_rows, sidecar_sheet, &mut diagnostics)?; + let snapshot = build_snapshot( + table_id, + schema, + uncertainty, + &sheet.name, + None, + data_rows, + &diagnostics, + store, + codecs, + )?; + imported.push(ImportedXlsxSheet { + name: sheet.name.clone(), + worksheet: sheet.name.clone(), + range: None, + snapshot, + diagnostics, + }); + continue; + } + let several = tables.len() > 1; + for table in tables { + let range = (several || !table.diagnostics.is_empty()).then(|| table.range.clone()); + imported.push(import_table( + table, + sheet, + range, + diagnostics.clone(), + store, + codecs, + )?); + } } Ok(imported) } +fn sheet_diagnostics(workbook_diagnostics: &[String], sheet: &XlsxSheet) -> Vec { + workbook_diagnostics + .iter() + .filter(|diagnostic| diagnostic.starts_with(&format!("{}!", sheet.name))) + .cloned() + .collect() +} + +fn import_table( + table: layout::LaidOutTable, + sheet: &XlsxSheet, + range: Option, + mut diagnostics: Vec, + store: &dyn BlockStore, + codecs: &CodecRegistry, +) -> Result { + diagnostics.extend(table.diagnostics); + let (table_id, schema, uncertainty) = + resolve_schema(&table.headers, &table.rows, None, &mut diagnostics)?; + let snapshot = build_snapshot( + table_id, + schema, + uncertainty, + &sheet.name, + range.as_deref(), + &table.rows, + &diagnostics, + store, + codecs, + )?; + Ok(ImportedXlsxSheet { + name: table.name, + worksheet: sheet.name.clone(), + range, + snapshot, + diagnostics, + }) +} + fn split_header<'a>( sheet: &'a XlsxSheet, diagnostics: &mut Vec, @@ -262,7 +333,8 @@ fn build_snapshot( table_id: TableId, schema: TableSchema, uncertainty: Vec, - sheet: &XlsxSheet, + worksheet: &str, + range: Option<&str>, rows: &[Vec], diagnostics: &[String], store: &dyn BlockStore, @@ -276,8 +348,14 @@ fn build_snapshot( ); builder.metadata_mut().insert( "space.nmrtist.plotium.import.worksheet".into(), - serde_json::Value::String(sheet.name.clone()), + serde_json::Value::String(worksheet.to_owned()), ); + if let Some(range) = range { + builder.metadata_mut().insert( + "space.nmrtist.plotium.import.xlsx.range".into(), + serde_json::Value::String(range.to_owned()), + ); + } builder.metadata_mut().insert( "space.nmrtist.plotium.import.diagnostics".into(), serde_json::Value::Array( diff --git a/crates/core/src/xlsx/layout.rs b/crates/core/src/xlsx/layout.rs new file mode 100644 index 00000000..dc1e1d86 --- /dev/null +++ b/crates/core/src/xlsx/layout.rs @@ -0,0 +1,719 @@ +//! Worksheet layout recognition for Source Data style sheets: several panels +//! side by side, multi-row headers, placeholder cells and stacked segments +//! separated by repeated header rows. Each recognized table is rewritten as a +//! single header row followed by aligned data rows. + +use plotium_io::xlsx::{XlsxCell, XlsxSheet, XlsxValue}; + +/// Cells that mark a missing value in a numeric column. +const PLACEHOLDERS: [&str; 11] = [ + "--", "-", "—", "–", "NaN", "nan", "N/A", "n/a", "NA", "n.d.", "N.D.", +]; + +pub(super) struct LaidOutTable { + pub name: String, + pub range: String, + pub headers: Vec, + pub rows: Vec>, + pub diagnostics: Vec, +} + +#[derive(Clone, Copy, PartialEq)] +enum Kind { + Empty, + Placeholder, + Value, + Text, +} + +fn kind(cell: Option<&XlsxCell>) -> Kind { + match cell.map(|cell| &cell.value) { + None | Some(XlsxValue::Empty) => Kind::Empty, + Some(XlsxValue::Error(_)) => Kind::Placeholder, + Some(XlsxValue::Utf8(text)) => { + let text = text.trim(); + if text.is_empty() { + Kind::Empty + } else if PLACEHOLDERS.contains(&text) { + Kind::Placeholder + } else if super::cell_type(&XlsxValue::Utf8(text.to_owned())) + .is_some_and(|kind| kind != plotium_data::LogicalType::Utf8) + { + Kind::Value + } else { + Kind::Text + } + } + Some(_) => Kind::Value, + } +} + +fn header_text(cell: Option<&XlsxCell>) -> String { + match cell.map(|cell| &cell.value) { + Some(XlsxValue::Utf8(text)) if text.trim() != "--" => text.trim().to_owned(), + Some(XlsxValue::Int64(value)) => value.to_string(), + Some(XlsxValue::Float64(value)) => value.to_string(), + Some(XlsxValue::Boolean(value)) => value.to_string(), + Some( + XlsxValue::DateTimeIso(text) + | XlsxValue::DurationIso(text) + | XlsxValue::ExcelDateTime { iso8601: text, .. }, + ) => text.clone(), + _ => String::new(), + } +} + +/// Absolute addressing over a sheet whose rows start at its origin. +pub(super) struct Grid<'a> { + sheet: &'a XlsxSheet, + pub rows: u32, + pub columns: u32, +} + +impl<'a> Grid<'a> { + pub(super) fn new(sheet: &'a XlsxSheet) -> Self { + let width = sheet.rows.iter().map(Vec::len).max().unwrap_or(0) as u32; + Self { + sheet, + rows: sheet.origin.0 + sheet.rows.len() as u32, + columns: sheet.origin.1 + width, + } + } + + fn get(&self, row: u32, column: u32) -> Option<&'a XlsxCell> { + let row = row.checked_sub(self.sheet.origin.0)?; + let column = column.checked_sub(self.sheet.origin.1)?; + self.sheet.rows.get(row as usize)?.get(column as usize) + } + + fn kind(&self, row: u32, column: u32) -> Kind { + kind(self.get(row, column)) + } + + pub(super) fn row_cells(&self, row: u32, columns: &[u32]) -> Vec { + columns + .iter() + .copied() + .filter(|&column| self.kind(row, column) != Kind::Empty) + .collect() + } +} + +/// Whether the sheet-level reader reads the sheet as it is: one row of distinct +/// text headers over one block whose columns each hold only values or only +/// text, so recognition has nothing to change. +fn is_plain(sheet: &XlsxSheet) -> bool { + let Some((header, data)) = sheet.rows.split_first() else { + return false; + }; + let width = sheet.rows.iter().map(Vec::len).max().unwrap_or(0); + let mut names = std::collections::HashSet::new(); + let header_ok = header.len() == width + && header.iter().all(|cell| { + matches!(&cell.value, XlsxValue::Utf8(text) if !text.trim().is_empty()) + && names.insert(header_text(Some(cell))) + }); + header_ok + && (0..width).all(|column| { + let kinds = data + .iter() + .map(|row| kind(row.get(column))) + .filter(|kind| *kind != Kind::Empty) + .collect::>(); + !kinds.is_empty() + && (kinds.iter().all(|kind| *kind == Kind::Value) + || kinds.iter().all(|kind| *kind != Kind::Value)) + }) +} + +/// Recognizes every table of a sheet. Returns nothing for plain sheets and +/// sheets without tables, leaving them to the sheet-level reader. +pub(super) fn auto_tables(sheet: &XlsxSheet) -> Vec { + if is_plain(sheet) { + return Vec::new(); + } + let grid = Grid::new(sheet); + let all = (sheet.origin.1..grid.columns).collect::>(); + let first_row = (sheet.origin.0..grid.rows).find(|&row| !grid.row_cells(row, &all).is_empty()); + let Some(first_row) = first_row else { + return Vec::new(); + }; + let letters = panel_letters(&grid, first_row, &all); + let body_start = if letters.is_empty() { + first_row + } else { + first_row + 1 + }; + let occupied = + |column: u32| (body_start..grid.rows).any(|row| grid.kind(row, column) != Kind::Empty); + let mut blocks: Vec> = Vec::new(); + for column in all { + let starts_panel = letters + .iter() + .any(|(letter_column, _)| *letter_column == column); + if (!occupied(column) || starts_panel) + && blocks.last().is_some_and(|block| !block.is_empty()) + { + blocks.push(Vec::new()); + } + if occupied(column) { + match blocks.last_mut() { + Some(block) => block.push(column), + None => blocks.push(vec![column]), + } + } + } + blocks.retain(|block| !block.is_empty()); + let single = blocks.len() == 1 && letters.is_empty(); + let mut tables = blocks + .iter() + .filter_map(|columns| { + let letter = letters + .iter() + .find(|(column, _)| *column == columns[0]) + .map(|(_, letter)| letter.clone()); + let mut table = lay_out(&grid, columns, body_start, grid.rows, None, true)?; + table.name = match (letter, table.title.first()) { + (Some(letter), _) => format!("{} {letter}", sheet.name), + (None, _) if single => sheet.name.clone(), + (None, Some(title)) => format!("{} {title}", sheet.name), + (None, None) => format!("{} {}", sheet.name, table.range), + }; + Some(table.finish(sheet, !single)) + }) + .collect::>(); + let names = tables + .iter() + .map(|table| table.name.clone()) + .collect::>(); + for (table, name) in tables.iter_mut().zip(unique(names)) { + table.name = name; + } + tables +} + +/// A first row of single letters over separate columns names side-by-side +/// panels. Adjacent letters are ordinary one-letter headers such as x and y. +fn panel_letters(grid: &Grid<'_>, row: u32, columns: &[u32]) -> Vec<(u32, String)> { + let cells = grid.row_cells(row, columns); + let letters = cells + .iter() + .filter_map(|&column| { + let text = header_text(grid.get(row, column)); + let letter = text.trim_matches(|c| c == '(' || c == ')'); + (letter.len() == 1 && letter.chars().all(char::is_alphabetic)) + .then(|| (column, letter.to_owned())) + }) + .collect::>(); + let separated = letters.windows(2).all(|pair| pair[1].0 > pair[0].0 + 1); + // Panels carry their own header rows; a header such as `t` stands + // directly over values. + let next = (row + 1..grid.rows).find(|&row| !grid.row_cells(row, columns).is_empty()); + let headed = next.is_some_and(|next| { + columns + .iter() + .all(|&column| grid.kind(next, column) != Kind::Value) + }); + if !letters.is_empty() && letters.len() == cells.len() && separated && headed { + letters + } else { + Vec::new() + } +} + +pub(super) struct Draft { + pub name: String, + pub range: String, + title: Vec, + headers: Vec, + rows: Vec>, + header_rows: usize, + notes: Vec, +} + +impl Draft { + pub(super) fn finish(self, sheet: &XlsxSheet, located: bool) -> LaidOutTable { + let mut diagnostics = Vec::new(); + if located || self.header_rows > 1 { + diagnostics.push(format!( + "Read {} of worksheet '{}' with {} header row(s).", + self.range, sheet.name, self.header_rows + )); + } + if !self.title.is_empty() { + diagnostics.push(format!("Title: {}.", self.title.join("; "))); + } + diagnostics.extend(self.notes); + LaidOutTable { + name: self.name, + range: self.range, + headers: self.headers, + rows: self.rows, + diagnostics, + } + } +} + +struct Column { + column: u32, + name: String, + /// Absolute row spans of the column's values, one per stacked segment. + segments: Vec<(u32, u32)>, + numeric: bool, +} + +pub(super) fn lay_out( + grid: &Grid<'_>, + columns: &[u32], + start: u32, + end: u32, + header_rows: Option, + split: bool, +) -> Option { + let rows = (start..end) + .filter(|&row| !grid.row_cells(row, columns).is_empty()) + .collect::>(); + let value_row = rows + .iter() + .copied() + .find(|&row| columns.iter().any(|&c| grid.kind(row, c) == Kind::Value)); + let first = match (header_rows, value_row) { + (Some(count), _) => start.checked_add(u32::try_from(count).ok()?)?, + (None, Some(row)) => row, + // A block of text alone, such as sample notes, keeps its top row as + // the header. + (None, None) => *rows.get(1)?, + }; + let last = *rows.last()?; + if first > last { + return None; + } + let header = rows + .iter() + .copied() + .filter(|&row| row < first) + .collect::>(); + let mut notes = Vec::new(); + let mut placeholders = 0; + let mut dropped = Vec::new(); + let mut kept = Vec::new(); + for &column in columns { + let kinds = (first..=last) + .map(|row| grid.kind(row, column)) + .collect::>(); + let values = kinds.iter().filter(|kind| **kind == Kind::Value).count(); + let texts = kinds.iter().filter(|kind| **kind == Kind::Text).count(); + if values == 0 && texts == 0 { + let label = header + .iter() + .map(|&row| header_text(grid.get(row, column))) + .filter(|text| !text.is_empty()) + .collect::>(); + if !label.is_empty() { + dropped.push(format!( + "{} ('{}')", + column_letters(column), + label.join(" ") + )); + } + continue; + } + kept.push((column, values > 0 && values >= texts)); + } + if !dropped.is_empty() { + notes.push(format!( + "Dropped columns with a header but no data: {}.", + dropped.join(", ") + )); + } + let data_columns = kept.iter().map(|(column, _)| *column).collect::>(); + // In a table with a text column, a full row of text headers ends the + // header: a first record such as a blank sample without a value is data. + let top = rows[0]; + if header_rows.is_none() + && header.len() > 1 + && kept.iter().any(|(_, numeric)| !numeric) + && data_columns + .iter() + .all(|&column| grid.kind(top, column) == Kind::Text) + { + return lay_out(grid, columns, top, end, Some(1), split); + } + let title = header + .iter() + .copied() + .filter(|&row| { + data_columns.len() > 1 + && grid.row_cells(row, &data_columns).len() <= 1 + && grid.row_cells(row, columns).len() == 1 + }) + .collect::>(); + let names_rows = header + .iter() + .copied() + .filter(|row| !title.contains(row)) + .collect::>(); + let full = names_rows + .iter() + .copied() + .filter(|&row| grid.row_cells(row, &data_columns).len() == data_columns.len()) + .collect::>(); + let period = full.last().and_then(|&row| { + period( + &data_columns + .iter() + .map(|&c| header_text(grid.get(row, c))) + .collect::>(), + ) + }); + let group_of = |index: usize| period.map_or(0, |period| index / period); + let scans = kept + .iter() + .map(|&(column, numeric)| numeric.then(|| scan(grid, column, first, last))) + .collect::>(); + let header_texts = names_rows + .iter() + .flat_map(|&row| columns.iter().map(move |&c| header_text(grid.get(row, c)))) + .filter(|text| !text.is_empty()) + .collect::>(); + // A text run splits a column into stacked segments only when every other + // numeric column of its group that runs across those rows pauses there + // too, as cyclers write a repeated header between charge and discharge + // (x columns pause on placeholders). A lone column splits only at a + // repeated header. Any other text inside values is a missing value. + let splits = |index: usize, gap: &Gap| { + let group = (0..scans.len()) + .filter(|&other| group_of(other) == group_of(index)) + .filter_map(|other| scans[other].as_ref().map(|scan| (other, scan))) + .collect::>(); + let overlaps = |other: &Gap| other.start <= gap.end && gap.start <= other.end; + let texts = group + .iter() + .flat_map(|(_, scan)| &scan.gaps) + .filter(|other| overlaps(other)) + .flat_map(|other| &other.texts) + .collect::>(); + let paused = group.iter().all(|(other, scan)| { + *other == index + || !(scan.span.0 < gap.start && gap.end < scan.span.1) + || scan.gaps.iter().any(overlaps) + }); + split + && paused + && !texts.is_empty() + && (group.len() > 1 || texts.iter().any(|text| header_texts.contains(*text))) + }; + let decisions = scans + .iter() + .enumerate() + .map(|(index, scan)| { + scan.as_ref().map_or_else(Vec::new, |scan| { + scan.gaps.iter().map(|gap| splits(index, gap)).collect() + }) + }) + .collect::>>(); + let mut built = Vec::new(); + let mut breaks = Vec::new(); + let mut trailing = Vec::new(); + let mut missing_text = Vec::new(); + for (index, ((&(column, numeric), scan), decisions)) in + kept.iter().zip(scans).zip(decisions).enumerate() + { + let mut parts = names_rows + .iter() + .filter_map(|&row| { + let text = match period { + Some(period) if !full.contains(&row) => { + let group = index / period * period; + data_columns[group..group + period] + .iter() + .map(|&c| header_text(grid.get(row, c))) + .find(|text| !text.is_empty()) + .unwrap_or_default() + } + _ => header_text(grid.get(row, column)), + }; + (!text.is_empty()).then_some(text) + }) + .collect::>(); + let segments = match scan { + Some(scan) => { + placeholders += scan.placeholders; + parts.extend(scan.leading); + trailing.extend(scan.trailing); + let mut segments = Vec::new(); + let mut start = scan.span.0; + for (gap, splits) in scan.gaps.into_iter().zip(decisions) { + if splits { + segments.push((start, gap.start - 1)); + start = gap.end + 1; + breaks.push(start); + } else { + missing_text.extend(gap.texts); + } + } + segments.push((start, scan.span.1)); + segments + } + None => { + let mut filled = + (first..=last).filter(|&row| grid.kind(row, column) != Kind::Empty); + let top = filled.next().unwrap_or(first); + vec![(top, filled.next_back().unwrap_or(top))] + } + }; + built.push(Column { + column, + name: if parts.is_empty() { + format!("Column {}", column_letters(column)) + } else { + parts.join(" ") + }, + segments, + numeric, + }); + } + if placeholders > 0 { + notes.push(format!( + "Read {placeholders} placeholder cell(s) such as '--' as missing values." + )); + } + if !breaks.is_empty() { + breaks.sort_unstable(); + breaks.dedup(); + let rows = breaks + .iter() + .map(|row| (row + 1).to_string()) + .collect::>(); + notes.push(format!( + "Split stacked segments separated by repeated header rows into separate columns (segments start at row {}).", + rows.join(", ") + )); + } + if !missing_text.is_empty() { + notes.push(format!( + "Read {} text cell(s) inside numeric columns as missing values: {}.", + missing_text.len(), + quoted(&missing_text) + )); + } + if !trailing.is_empty() { + notes.push(format!( + "Ignored {} text cell(s) below the data in numeric columns: {}.", + trailing.len(), + quoted(&trailing) + )); + } + if built + .iter() + .all(|column| column.name.starts_with("Column ")) + { + notes.push("No header row was found; named columns by their letters.".into()); + } + let (headers, rows) = assemble(grid, &built, period, first); + Some(Draft { + name: String::new(), + range: format!( + "{}{}:{}{}", + column_letters(columns[0]), + header.first().unwrap_or(&first) + 1, + column_letters(*columns.last()?), + last + 1 + ), + title: title + .iter() + .map(|&row| { + grid.row_cells(row, columns) + .first() + .map(|&c| header_text(grid.get(row, c))) + .unwrap_or_default() + }) + .collect(), + headers: unique(headers), + rows, + header_rows: header.len(), + notes, + }) +} + +/// A run of rows without values between two values of a column. +struct Gap { + start: u32, + end: u32, + texts: Vec, +} + +struct Scan { + /// First and last value rows. + span: (u32, u32), + gaps: Vec, + /// Text above the first value, read as more header. + leading: Vec, + /// Text below the last value, such as notes. + trailing: Vec, + placeholders: usize, +} + +fn scan(grid: &Grid<'_>, column: u32, first: u32, last: u32) -> Scan { + let mut result = Scan { + span: (first, first), + gaps: Vec::new(), + leading: Vec::new(), + trailing: Vec::new(), + placeholders: 0, + }; + let mut seen_value = false; + let mut gap: Option = None; + for row in first..=last { + match grid.kind(row, column) { + Kind::Value => { + if !seen_value { + result.span.0 = row; + seen_value = true; + } else if let Some(mut gap) = gap.take() { + gap.end = row - 1; + result.gaps.push(gap); + } + result.trailing.clear(); + result.span.1 = row; + } + kind if seen_value => { + let entry = gap.get_or_insert(Gap { + start: row, + end: row, + texts: Vec::new(), + }); + if kind == Kind::Text { + let text = header_text(grid.get(row, column)); + entry.texts.push(text.clone()); + result.trailing.push(text); + } + result.placeholders += usize::from(kind == Kind::Placeholder); + } + Kind::Text => result.leading.push(header_text(grid.get(row, column))), + _ => {} + } + } + result +} + +fn quoted(texts: &[String]) -> String { + let shown = texts + .iter() + .take(4) + .map(|text| format!("'{text}'")) + .collect::>() + .join(", "); + if texts.len() > 4 { + format!("{shown}, …") + } else { + shown + } +} + +/// Orders output columns segment by segment within each header period, so +/// x/y pairs stay adjacent, and aligns each segment at its earliest start. +fn assemble( + grid: &Grid<'_>, + columns: &[Column], + period: Option, + first: u32, +) -> (Vec, Vec>) { + let group = period.unwrap_or(columns.len()).max(1); + let segment_count = + |columns: &[Column]| columns.iter().map(|c| c.segments.len()).max().unwrap_or(0); + let starts = (0..segment_count(columns)) + .map(|segment| { + columns + .iter() + .filter_map(|c| c.segments.get(segment).map(|span| span.0)) + .min() + .unwrap_or(first) + }) + .collect::>(); + let mut order = Vec::new(); + for chunk in columns.chunks(group) { + for segment in 0..segment_count(chunk) { + for column in chunk.iter().filter(|c| segment < c.segments.len()) { + order.push((column, segment)); + } + } + } + let headers = order + .iter() + .map(|(column, segment)| { + if column.segments.len() > 1 { + format!("{} ({})", column.name, segment + 1) + } else { + column.name.clone() + } + }) + .collect(); + let height = order + .iter() + .map(|(column, segment)| column.segments[*segment].1 + 1 - starts[*segment]) + .max() + .unwrap_or(0); + let rows = (0..height) + .map(|offset| { + order + .iter() + .map(|(column, segment)| { + let (from, to) = column.segments[*segment]; + let row = starts[*segment] + offset; + let cell = grid + .get(row, column.column) + .filter(|_| (from..=to).contains(&row)); + match (cell, kind(cell)) { + (Some(cell), Kind::Value) => cell.clone(), + (Some(cell), Kind::Text) if !column.numeric => cell.clone(), + (Some(cell), Kind::Placeholder) if !column.numeric => cell.clone(), + _ => XlsxCell::value(XlsxValue::Empty), + } + }) + .collect() + }) + .collect(); + (headers, rows) +} + +/// The shortest repeat of the bottom header row, such as `2 theta, intensity` +/// repeated for each sample. +fn period(names: &[String]) -> Option { + let count = names.len(); + (1..count) + .filter(|period| count.is_multiple_of(*period)) + .find(|&period| (period..count).all(|index| names[index] == names[index % period])) +} + +fn unique(names: Vec) -> Vec { + let mut seen = std::collections::HashSet::new(); + names + .into_iter() + .map(|name| { + let mut candidate = name.clone(); + let mut index = 2; + while !seen.insert(candidate.clone()) { + candidate = format!("{name} ({index})"); + index += 1; + } + candidate + }) + .collect() +} + +pub(super) fn column_letters(column: u32) -> String { + let mut column = column + 1; + let mut letters = Vec::new(); + while column > 0 { + let rem = (column - 1) % 26; + letters.push(b'A' + rem as u8); + column = (column - 1) / 26; + } + letters.reverse(); + String::from_utf8(letters).expect("ASCII letters") +} + +#[cfg(test)] +#[path = "layout_tests.rs"] +mod tests; diff --git a/crates/core/src/xlsx/layout_tests.rs b/crates/core/src/xlsx/layout_tests.rs new file mode 100644 index 00000000..636ff5b2 --- /dev/null +++ b/crates/core/src/xlsx/layout_tests.rs @@ -0,0 +1,312 @@ +use super::*; +use crate::xlsx::regions::{XlsxRegion, parse_range}; +use crate::xlsx::{ImportedXlsxSheet, import_xlsx_regions, import_xlsx_workbook}; +use plotium_data::{CodecRegistry, LogicalType, MemoryBlockStore}; +use plotium_io::xlsx::XlsxWorkbook; + +fn t(value: &str) -> XlsxCell { + XlsxCell::value(XlsxValue::Utf8(value.into())) +} + +fn n(value: f64) -> XlsxCell { + XlsxCell::value(XlsxValue::Float64(value)) +} + +fn e() -> XlsxCell { + XlsxCell::value(XlsxValue::Empty) +} + +fn sheet(rows: Vec>) -> XlsxSheet { + XlsxSheet { + name: "Figure 3".into(), + hidden: false, + origin: (0, 0), + rows, + } +} + +fn import(sheet: XlsxSheet) -> Vec { + let workbook = XlsxWorkbook { + sheets: vec![sheet], + uses_1904_date_system: false, + plotium_schema: None, + diagnostics: Vec::new(), + }; + import_xlsx_workbook( + &workbook, + &MemoryBlockStore::default(), + &CodecRegistry::with_arrow_ipc(), + ) + .unwrap() +} + +fn names(table: &ImportedXlsxSheet) -> Vec<&str> { + table + .snapshot + .schema + .columns + .iter() + .map(|column| column.name.as_str()) + .collect() +} + +#[test] +fn plain_sheets_read_as_before() { + let tables = import(sheet(vec![ + vec![t("sample"), t("x"), t("y")], + vec![t("a"), n(1.0), n(2.0)], + vec![t("b"), n(2.0), e()], + ])); + assert_eq!(tables.len(), 1); + assert_eq!(tables[0].name, "Figure 3"); + assert_eq!(tables[0].range, None); + assert!( + tables[0].diagnostics.is_empty(), + "{:?}", + tables[0].diagnostics + ); + assert_eq!(names(&tables[0]), ["sample", "x", "y"]); + assert_eq!(tables[0].snapshot.row_count, 2); + assert_eq!( + tables[0].snapshot.schema.columns[0].logical_type, + LogicalType::Utf8 + ); +} + +#[test] +fn panel_letters_and_blank_columns_split_side_by_side_tables() { + // Panel b starts right after a header-only column of panel a, with no + // blank column between them. + let tables = import(sheet(vec![ + vec![t("a"), e(), e(), t("b"), e(), e(), e(), e()], + vec![ + t("x"), + t("y"), + t("orphan"), + t("t"), + t("I"), + e(), + t("u"), + t("v"), + ], + vec![n(0.0), n(1.0), e(), n(5.0), n(6.0), e(), n(7.0), n(8.0)], + vec![n(1.0), n(2.0), e(), n(6.0), n(7.0), e(), n(8.0), n(9.0)], + ])); + let summary = tables + .iter() + .map(|table| (table.name.as_str(), table.range.as_deref(), names(table))) + .collect::>(); + assert_eq!( + summary, + [ + ("Figure 3 a", Some("A2:C4"), vec!["x", "y"]), + ("Figure 3 b", Some("D2:E4"), vec!["t", "I"]), + ("Figure 3 G2:H4", Some("G2:H4"), vec!["u", "v"]), + ] + ); + assert!( + tables[0] + .diagnostics + .iter() + .any(|note| note.contains("C ('orphan')")) + ); +} + +#[test] +fn adjacent_single_letter_headers_are_column_names() { + let tables = import(sheet(vec![vec![t("x"), t("y")], vec![n(0.0), n(1.0)]])); + assert_eq!(tables.len(), 1); + assert_eq!(names(&tables[0]), ["x", "y"]); +} + +#[test] +fn header_rows_combine_titles_groups_and_units() { + // Sample names sit over the y column of each x/y pair. + let tables = import(sheet(vec![ + vec![t("XPS"), e(), e(), e()], + vec![e(), t("before"), e(), t("after")], + vec![t("Energy"), t("Intensity"), t("Energy"), t("Intensity")], + vec![t("eV"), t("--"), t("eV"), t("--")], + vec![n(1.0), n(10.0), n(1.0), n(12.0)], + ])); + assert_eq!( + names(&tables[0]), + [ + "before Energy eV", + "before Intensity", + "after Energy eV", + "after Intensity" + ] + ); + let notes = tables[0].diagnostics.join("\n"); + assert!(notes.contains("with 4 header row(s)"), "{notes}"); + assert!(notes.contains("Title: XPS."), "{notes}"); +} + +#[test] +fn stacked_segments_split_into_aligned_columns() { + // The x column pauses on placeholders where the y column repeats its + // header, as cyclers export charge and discharge. + let tables = import(sheet(vec![ + vec![t("Capacity"), t("Voltage")], + vec![n(0.0), n(2.0)], + vec![n(1.0), n(4.0)], + vec![t("--"), t("Voltage")], + vec![n(0.0), n(4.0)], + vec![n(2.0), n(2.5)], + vec![n(3.0), n(2.0)], + vec![t("end"), e()], + ])); + let table = &tables[0]; + assert_eq!( + names(table), + ["Capacity (1)", "Voltage (1)", "Capacity (2)", "Voltage (2)"] + ); + assert_eq!(table.snapshot.row_count, 3); + assert!( + table + .snapshot + .schema + .columns + .iter() + .all(|column| column.logical_type == LogicalType::Float64) + ); + let notes = table.diagnostics.join("\n"); + assert!(notes.contains("segments start at row 5"), "{notes}"); + assert!(notes.contains("'end'"), "{notes}"); +} + +#[test] +fn regions_override_header_rows_names_and_segments() { + let sheet = sheet(vec![ + vec![t("2 theta"), n(1.0), n(2.0)], + vec![n(10.0), n(5.0), n(6.0)], + vec![t("note"), e(), e()], + vec![n(11.0), n(7.0), n(8.0)], + ]); + let workbook = XlsxWorkbook { + sheets: vec![sheet], + uses_1904_date_system: false, + plotium_schema: None, + diagnostics: Vec::new(), + }; + let region = XlsxRegion { + sheet: "Figure 3".into(), + range: "A1:C4".into(), + header_rows: Some(1), + name: Some("XRD".into()), + split_segments: false, + }; + let tables = import_xlsx_regions( + &workbook, + &[region], + &MemoryBlockStore::default(), + &CodecRegistry::with_arrow_ipc(), + ) + .unwrap(); + assert_eq!(tables[0].name, "XRD"); + assert_eq!(tables[0].range.as_deref(), Some("A1:C4")); + assert_eq!(names(&tables[0]), ["2 theta", "1", "2"]); + assert_eq!(tables[0].snapshot.row_count, 3); +} + +#[test] +fn ranges_parse_cells_and_whole_columns() { + assert_eq!( + parse_range("J2:AC4411"), + Ok(((Some(1), Some(4410)), (9, 28))) + ); + assert_eq!(parse_range("j:ac"), Ok(((None, None), (9, 28)))); + for invalid in ["J2", "J2:AC", "AC2:J9", "J0:K2", "2:3"] { + assert!(parse_range(invalid).is_err(), "{invalid}"); + } + assert_eq!(column_letters(0), "A"); + assert_eq!(column_letters(28), "AC"); +} + +fn assert_float64(table: &ImportedXlsxSheet, column: usize) { + let schema = &table.snapshot.schema.columns[column]; + assert_eq!(schema.logical_type, LogicalType::Float64, "{}", schema.name); +} + +#[test] +fn text_inside_values_is_missing_and_keeps_rows_aligned() { + let tables = import(sheet(vec![ + vec![t("x"), t("y")], + vec![n(1.0), n(10.0)], + vec![n(2.0), t(">(); + assert_eq!(summary, [vec!["x", "y"], vec!["Sample", "Note"]]); +} + +#[test] +fn regions_are_clamped_and_reject_header_rows_beyond_the_range() { + let workbook = XlsxWorkbook { + sheets: vec![sheet(vec![vec![t("x")], vec![n(1.0)]])], + uses_1904_date_system: false, + plotium_schema: None, + diagnostics: Vec::new(), + }; + let region = |range: &str, header_rows| XlsxRegion { + sheet: "Figure 3".into(), + range: range.into(), + header_rows, + name: None, + split_segments: true, + }; + let import = |region| { + import_xlsx_regions( + &workbook, + &[region], + &MemoryBlockStore::default(), + &CodecRegistry::with_arrow_ipc(), + ) + }; + let wide = import(region("A:ZZZZZ", None)).unwrap(); + assert_eq!(wide[0].range.as_deref(), Some("A1:A2")); + assert!(import(region("A1:A2", Some(usize::MAX))).is_err()); + assert!(import(region("C1:D9", None)).is_err()); +} diff --git a/crates/core/src/xlsx/regions.rs b/crates/core/src/xlsx/regions.rs new file mode 100644 index 00000000..fcf0beca --- /dev/null +++ b/crates/core/src/xlsx/regions.rs @@ -0,0 +1,154 @@ +//! Explicit worksheet regions, for sheets whose recognized layout is wrong. +use super::{ + CodecRegistry, ImportedXlsxSheet, XlsxImportError, format_xlsx_diagnostic, import_table, + layout::{Grid, LaidOutTable, lay_out}, + sheet_diagnostics, +}; +use plotium_data::BlockStore; +use plotium_io::xlsx::{XlsxSheet, XlsxWorkbook}; +use serde::{Deserialize, Serialize}; + +/// Imports each explicit region of a workbook as one table. +pub fn import_xlsx_regions( + workbook: &XlsxWorkbook, + regions: &[XlsxRegion], + store: &dyn BlockStore, + codecs: &CodecRegistry, +) -> Result, XlsxImportError> { + let workbook_diagnostics = workbook + .diagnostics + .iter() + .map(format_xlsx_diagnostic) + .collect::>(); + regions + .iter() + .map(|region| { + let sheet = workbook + .sheets + .iter() + .find(|sheet| !sheet.hidden && sheet.name == region.sheet) + .ok_or_else(|| { + XlsxImportError::Region(format!( + "Worksheet '{}' is not a visible worksheet of this workbook.", + region.sheet + )) + })?; + let table = region_table(sheet, region).map_err(XlsxImportError::Region)?; + let range = Some(table.range.clone()); + let diagnostics = sheet_diagnostics(&workbook_diagnostics, sheet); + import_table(table, sheet, range, diagnostics, store, codecs) + }) + .collect() +} + +/// An explicit rectangle of a worksheet to import as one table. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct XlsxRegion { + pub sheet: String, + /// A1 range such as `J2:AC4411`, or whole columns such as `J:AC`. + pub range: String, + /// Header rows, counted from the first non-empty row of the range; + /// detected when absent. + pub header_rows: Option, + pub name: Option, + /// Splits a column where a text run separates two runs of values. + #[serde(default = "split_segments_default")] + pub split_segments: bool, +} + +fn split_segments_default() -> bool { + true +} + +/// Lays out one explicit region. +fn region_table(sheet: &XlsxSheet, region: &XlsxRegion) -> Result { + let (rows, columns) = parse_range(®ion.range)?; + let grid = Grid::new(sheet); + let outside = || { + format!( + "Range {} of worksheet '{}' holds no cells.", + region.range, sheet.name + ) + }; + // Only the used area holds cells, so a range is clamped to it before any + // row or column is visited. + let columns = (columns.0.max(sheet.origin.1)..=columns.1.min(grid.columns.saturating_sub(1))) + .collect::>(); + let end = rows + .1 + .map_or(grid.rows, |end| end.saturating_add(1).min(grid.rows)); + let start = rows.0.unwrap_or(0).max(sheet.origin.0).min(end); + let start = (start..end) + .find(|&row| !grid.row_cells(row, &columns).is_empty()) + .ok_or_else(outside)?; + if let Some(count) = region.header_rows + && u32::try_from(count).map_or(true, |count| count >= end - start) + { + return Err(format!( + "header_rows {count} leaves no data rows in range {} of worksheet '{}'.", + region.range, sheet.name + )); + } + let mut table = lay_out( + &grid, + &columns, + start, + end, + region.header_rows, + region.split_segments, + ) + .ok_or_else(outside)?; + table.name = region + .name + .clone() + .unwrap_or_else(|| format!("{} {}", sheet.name, table.range)); + Ok(table.finish(sheet, true)) +} + +/// Zero-based first and last rows; absent for whole columns. +type RowBounds = (Option, Option); + +/// Parses `J2:AC4411` or `J:AC` into zero-based row bounds and column bounds. +pub(super) fn parse_range(range: &str) -> Result<(RowBounds, (u32, u32)), String> { + let invalid = || format!("Range '{range}' is not an A1 range such as J2:AC4411 or J:AC."); + let (from, to) = range.trim().split_once(':').ok_or_else(invalid)?; + let cell = |text: &str| -> Result<(u32, Option), String> { + let split = text + .find(|c: char| c.is_ascii_digit()) + .unwrap_or(text.len()); + let (letters, digits) = text.split_at(split); + if letters.is_empty() || !letters.chars().all(|c| c.is_ascii_alphabetic()) { + return Err(invalid()); + } + let column = letters + .chars() + .try_fold(0u32, |acc, c| { + acc.checked_mul(26)? + .checked_add(c.to_ascii_uppercase() as u32 - 'A' as u32 + 1) + }) + .ok_or_else(invalid)? + - 1; + let row = match digits { + "" => None, + digits => Some( + digits + .parse::() + .ok() + .filter(|row| *row > 0) + .ok_or_else(invalid)? + - 1, + ), + }; + Ok((column, row)) + }; + let (first_column, first_row) = cell(from)?; + let (last_column, last_row) = cell(to)?; + if first_row.is_some() != last_row.is_some() + || first_column > last_column + || first_row > last_row + { + return Err(invalid()); + } + Ok(((first_row, last_row), (first_column, last_column))) +} diff --git a/crates/core/src/xlsx/tests.rs b/crates/core/src/xlsx/tests.rs index 54279352..5a77e6f9 100644 --- a/crates/core/src/xlsx/tests.rs +++ b/crates/core/src/xlsx/tests.rs @@ -7,6 +7,7 @@ fn imports_mixed_null_and_iso_columns_without_discarding_them() { sheets: vec![XlsxSheet { name: "Data".into(), hidden: false, + origin: (0, 0), rows: vec![ vec![text("sample"), text("value"), text("when")], vec![text("a"), number(1.0), text("2026-07-20")], diff --git a/crates/io/src/xlsx.rs b/crates/io/src/xlsx.rs index 6b393d2d..2283be67 100644 --- a/crates/io/src/xlsx.rs +++ b/crates/io/src/xlsx.rs @@ -28,6 +28,10 @@ pub struct XlsxWorkbook { pub struct XlsxSheet { pub name: String, pub hidden: bool, + /// Zero-based worksheet row and column of `rows[0][0]`. Readers start at + /// the used range; writers always start at A1. + #[serde(default)] + pub origin: (u32, u32), pub rows: Vec>, } @@ -181,6 +185,7 @@ impl XlsxReader { let formulas = self.reader.worksheet_formula(name)?; let start = union_start(values.start(), formulas.start()); let end = union_end(values.end(), formulas.end()); + let origin = start.unwrap_or_default(); let rows = match (start, end) { (Some(start), Some(end)) => { read_rows(name, start, end, &values, &formulas, &mut self.diagnostics) @@ -190,6 +195,7 @@ impl XlsxReader { Ok(XlsxSheet { name: name.to_owned(), hidden, + origin, rows, }) } @@ -520,6 +526,7 @@ mod tests { sheets: vec![XlsxSheet { name: "Data".to_owned(), hidden: false, + origin: (0, 0), rows: vec![ vec![ XlsxCell::value(XlsxValue::Utf8("sample".to_owned())), @@ -552,11 +559,13 @@ mod tests { XlsxSheet { name: "Samples".to_owned(), hidden: false, + origin: (0, 0), rows: vec![vec![XlsxCell::value(XlsxValue::Utf8("sample".into()))]], }, XlsxSheet { name: "Measurements".to_owned(), hidden: false, + origin: (0, 0), rows: vec![vec![XlsxCell::value(XlsxValue::Float64(2.5))]], }, ], @@ -599,6 +608,7 @@ mod tests { sheets: vec![XlsxSheet { name: "Data".to_owned(), hidden: false, + origin: (0, 0), rows: vec![vec![XlsxCell::value(XlsxValue::Int64(1))]], }], uses_1904_date_system: true, diff --git a/docs/src/content/docs/guides/importing-data.md b/docs/src/content/docs/guides/importing-data.md index c48d7a84..43c71554 100644 --- a/docs/src/content/docs/guides/importing-data.md +++ b/docs/src/content/docs/guides/importing-data.md @@ -50,8 +50,8 @@ Importing a table, from a file or the clipboard, first opens a **Review table import** dialog. It shows each column's inferred type and unit, whether the column allows empty cells, a preview of the first rows, and any import diagnostics. Choose **Import table** to add it, or **Cancel** to leave your -project and recent-file list untouched. An XLSX workbook with several sheets -adds a **Table** selector so you can preview each worksheet; a single **Import +project and recent-file list untouched. An XLSX workbook with several tables +adds a **Table** selector so you can preview each one; a single **Import table** brings them all in as separate tables. While a preview is pending, selecting a second table file is rejected; finish or cancel the current preview first. @@ -112,6 +112,32 @@ formula cell with no cached value imports as empty and is listed in the diagnostics. Exported XLSX files hold plain values, so they never depend on Excel recalculating them. +### Source Data worksheets + +Worksheets laid out like journal Source Data are split into clean tables: + +- **Side-by-side panels.** Fully empty columns separate tables, and so do + panel letters (`a`, `b`, …) on the first row. Each table is named after the + worksheet and its panel letter, such as *Figure 3 b*; without a letter, the + name adds the panel's title row or its range, such as *Figure 2 H1:W2003*. +- **Several header rows.** All rows above the first value form the header. A + row with a single cell is the table's title; the other rows are joined into + each column's name. Sample names written over only one column of each x/y + pair are shared by the whole pair. Columns without a header are named by + their letter, such as *Column AE*. +- **Placeholders.** In numeric columns, placeholders such as `--`, `NaN`, + `N/A` and `n.d.`, and any other text among the values, are read as empty + cells; notes below the values are ignored. +- **Stacked segments.** When a repeated header row interrupts the numeric + columns of a group, as cyclers export charge and discharge, each segment + becomes its own column, numbered *(1)*, *(2)*, … and aligned with the + matching x column. A group is the columns that share one sample name, or + the whole table when there are no sample names. + +The diagnostics give each table's range and every change, so you can check +them in the review dialog. A worksheet with one row of distinct text headers +over one block of plain data is read as it is. + ## Supplying a missing NMR sampling table Use this option for supported 2D Bruker NUS or JEOL acquisitions that sampled only diff --git a/docs/src/content/docs/guides/supported-formats.md b/docs/src/content/docs/guides/supported-formats.md index 014b6ce5..0cba543c 100644 --- a/docs/src/content/docs/guides/supported-formats.md +++ b/docs/src/content/docs/guides/supported-formats.md @@ -23,7 +23,7 @@ see [Importing data](/guides/importing-data/). | CasaXPS text | `.txt` | Structured eight-line export with raw spectrum, background, envelope, components, and fitted parameters | | Bruker NanoScope AFM | `.spm` / `.pfc` | Images, force curves, force-volume and PeakForce Capture cubes | | Axon Binary Format 2 | `.abf` | int16/float32, multiple channels and sweeps, embedded DAC/epoch stimuli | -| Tabular data | `.csv`, `.tsv`, `.txt`, `.xlsx` | Column types and empty cells preserved; one table per XLSX worksheet | +| Tabular data | `.csv`, `.tsv`, `.txt`, `.xlsx` | Column types and empty cells preserved; one table per XLSX worksheet or Source Data panel | | Origin project (experimental) | `.opj`, `.opju` | Worksheets from two verified OPJ profiles; graphs are not imported, and `.opju` is detection-only | | Zip archive | `.zip` | An archived dataset folder | | Plotium project | `.plotium` | Full project: data, processing, and layout; see [Plotium files](/reference/file-formats/) | diff --git a/docs/src/content/docs/reference/mcp/tables.md b/docs/src/content/docs/reference/mcp/tables.md index 71260b42..4dca17d9 100644 --- a/docs/src/content/docs/reference/mcp/tables.md +++ b/docs/src/content/docs/reference/mcp/tables.md @@ -85,9 +85,19 @@ An `.xlsx` file (or `format: "xlsx"`) imports each visible worksheet that has cells as its own table, named after the worksheet, or after the file when the workbook holds a single table, the same way the desktop imports it. `sheets: ["Figure 3", …]` imports only the named worksheets; an unknown name -fails that file and lists the visible worksheets. Each worksheet is prepared on -its own: one that holds no table or exceeds a limit fails as its own item and -the others still import. One file reads at most 8,000,000 worksheet cells, +fails that file and lists the visible worksheets. Worksheets laid out like +Source Data are split into tables as described in +[Importing data](/guides/importing-data/#source-data-worksheets); each item +then reports its `range`. When a table is recognized wrongly, import it again +with `regions: [{"sheet": "Figure 3", "range": "J2:AC4411", "header_rows": 1}]`. +Each region becomes one table, named `name` or the worksheet and range; +`header_rows` counts from the first non-empty row of the range and defaults to +the rows above the first value, and `split_segments: false` keeps stacked +segments in one column. With `regions`, only the regions and any worksheets in +`sheets` are imported. Each worksheet is prepared on its own: one that holds no +table or exceeds a limit, or a region that holds no cells, fails as its own +item and the others still import. A table wider than 256 columns fails and +asks for a narrower region. One file reads at most 8,000,000 worksheet cells, counted over each worksheet's used range. `header`, `schema` and `schema_path` apply to CSV/TSV only. If the summary of a batch with XLSX files would exceed 128 KiB, the preflight fails and lists each workbook's worksheets, so you can diff --git a/docs/src/content/docs/zh-cn/guides/importing-data.md b/docs/src/content/docs/zh-cn/guides/importing-data.md index 014fc03e..9dd13afe 100644 --- a/docs/src/content/docs/zh-cn/guides/importing-data.md +++ b/docs/src/content/docs/zh-cn/guides/importing-data.md @@ -40,7 +40,7 @@ ZIP 文件和手动补录采样表的导入使用各自的导入流程。 无论从文件还是剪贴板导入表格,都会先打开 **Review table import** 对话框。它会 列出每列推断出的类型和单位、该列是否允许空单元格、前几行的预览,以及任何导入 诊断。选择 **Import table** 导入,或选择 **Cancel** 保持项目与最近文件列表不变。 -含多个工作表的 XLSX 会额外提供 **Table** 选择器,可逐一预览工作簿中的各工作表; +含多张表的 XLSX 会额外提供 **Table** 选择器,可逐一预览各张表; 一次 **Import table** 会把它们作为独立数据表全部导入。预览尚未处理完时,再选择 第二个表格文件会被拒绝;请先完成或取消当前预览。 @@ -90,6 +90,25 @@ Plotium 导出 CSV 或 TSV 时,会在旁边写入一个配套的 `.plotium-sch 没有缓存值的公式单元格会以空导入,并列入诊断。导出的 XLSX 文件只包含确定值, 因此不依赖 Excel 重新计算。 +### Source Data 工作表 + +按期刊 Source Data 方式排布的工作表会拆成规整的数据表: + +- **横排的多个面板。** 整列空白处分开,第一行的面板字母(`a`、`b`……)也作为分界。 + 每张表以工作表名加面板字母命名,例如 *Figure 3 b*;没有字母时加面板的标题行或 + 范围,例如 *Figure 2 H1:W2003*。 +- **多行表头。** 第一个数值之上的各行都是表头。只有一个单元格的行是表的标题,其余各行 + 拼成每列的列名。样品名只写在每组 x/y 列其中一列上时,由整组共用。没有表头的列以列 + 字母命名,例如 *Column AE*。 +- **占位符。** 数值列中的 `--`、`NaN`、`N/A`、`n.d.` 等占位符,以及夹在数值中间的其他 + 文字,都按空单元格读取;数值下方的备注忽略。 +- **上下拼接的段。** 一组数值列中间同时夹着重复表头时(例如充放电仪导出的充电段和放电 + 段),每段成为单独的一列,编号为 *(1)*、*(2)*……,并与对应的 x 列对齐。一组指共用 + 同一个样品名的各列;没有样品名时整张表为一组。 + +诊断会给出每张表的范围和所做的每处调整,可在检查对话框中核对。只有一行互不重复的文字 +表头、下面是一块普通数据的工作表,按原样读取。 + ## 补录缺失的 NMR 采样表 此选项适用于受支持的二维 Bruker NUS 或只采集了部分间接网格点的 JEOL 数据。 diff --git a/docs/src/content/docs/zh-cn/guides/supported-formats.md b/docs/src/content/docs/zh-cn/guides/supported-formats.md index db49bc33..b43a7398 100644 --- a/docs/src/content/docs/zh-cn/guides/supported-formats.md +++ b/docs/src/content/docs/zh-cn/guides/supported-formats.md @@ -22,7 +22,7 @@ Plotium 直接读取厂商 NMR、LC–MS、XRD、XPS、AFM 与电生理格式, | CasaXPS 文本 | `.txt` | 含原始谱、背景、包络、组件和拟合参数的结构化八行头导出 | | Bruker NanoScope AFM | `.spm` / `.pfc` | 图像、力曲线、Force Volume 与 PeakForce Capture 数据立方体 | | Axon Binary Format 2 | `.abf` | int16/float32、多通道、多 sweep,以及文件内 DAC/epoch 刺激 | -| 表格数据 | `.csv`、`.tsv`、`.txt`、`.xlsx` | 保留列类型与空单元格;每个 XLSX 工作表导入为独立数据表 | +| 表格数据 | `.csv`、`.tsv`、`.txt`、`.xlsx` | 保留列类型与空单元格;每个 XLSX 工作表或 Source Data 面板导入为独立数据表 | | Origin 项目(实验性) | `.opj`、`.opju` | 两种经验证的 OPJ 配置中的工作表;不导入图形,`.opju` 仅作识别 | | Zip 压缩包 | `.zip` | 打包的数据文件夹 | | Plotium 项目 | `.plotium` | 完整项目:数据、处理与排版;见 [Plotium 文件](/zh-cn/reference/file-formats/) | diff --git a/docs/src/content/docs/zh-cn/reference/mcp/tables.md b/docs/src/content/docs/zh-cn/reference/mcp/tables.md index 0f8b8b19..e9f810df 100644 --- a/docs/src/content/docs/zh-cn/reference/mcp/tables.md +++ b/docs/src/content/docs/zh-cn/reference/mcp/tables.md @@ -71,7 +71,14 @@ CSV、TSV 和 XLSX 文件是表格,用 `mode: "table_data_only"` 导入;原 `format: "csv" | "tsv"` 指定。`.xlsx` 文件(或 `format: "xlsx"`)把每个有内容的可见 工作表各导入成一张表,表名取工作表名,工作簿只有一张表时取文件名,与桌面端导入一致。 `sheets: ["Figure 3", …]` 只导入列出的工作表;名字不存在时该文件失败,并列出可见工作表。 -每个工作表单独准备:没有表格或超出限制的工作表作为单独一项失败,其余照常导入。一个文件 +按 Source Data 方式排布的工作表会按[导入数据](/zh-cn/guides/importing-data/#source-data-工作表) +中的规则拆成多张表,每项会报告其 `range`。某张表识别不对时,用 +`regions: [{"sheet": "Figure 3", "range": "J2:AC4411", "header_rows": 1}]` 重新导入。 +每个区域成为一张表,名称取 `name`,否则取工作表名加范围;`header_rows` 从区域内第一个 +非空行起算,默认为第一个数值之上的行数;`split_segments: false` 让上下拼接的段保留在 +同一列。给了 `regions` 时,只导入这些区域和 `sheets` 中列出的工作表。每个工作表单独 +准备:没有表格或超出限制的工作表、没有单元格的区域都作为单独一项失败,其余照常导入。 +超过 256 列的表会失败,并提示改用更窄的区域。一个文件 最多读取 8,000,000 个工作表单元格,按各工作表的已用范围计算。`header`、`schema` 和 `schema_path` 只用于 CSV/TSV。含 XLSX 的批次摘要超过 128 KiB 时预检失败,并按文件列出 工作表,可每次导入一个工作簿并减少 `sheets` 中的工作表。只读所列普通文件,不扫描目录、不自动寻找邻接