diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs index 10e17dc44..33dc84936 100644 --- a/apps/gridcraft-web/src/web.rs +++ b/apps/gridcraft-web/src/web.rs @@ -193,7 +193,7 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { let ctx = ctx.clone(); wasm_bindgen_futures::spawn_local(async move { let Some(file) = rfd::AsyncFileDialog::new() - .add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "json"]) + .add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "slk", "json"]) .pick_file() .await else { diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index e99ef15de..2aa98b6eb 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -171,7 +171,7 @@ fn services() -> Services { })), pick_open: Some(Box::new(|| { rfd::FileDialog::new() - .add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "json"]) + .add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "slk", "json"]) .add_filter("Excel Workbook", &["xlsx", "xlsm"]) .add_filter("Excel 97-2003 Workbook (import)", &["xls"]) .add_filter("Excel Binary Workbook (data import)", &["xlsb"]) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 913fcdd5a..2234d4627 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -1,4 +1,4 @@ -//! Opening and saving files: XLSX, legacy XLS import, XLSB and ODS data import, CSV/TSV, JSON (debug), HTML export. +//! Opening and saving files: XLSX, legacy XLS import, XLSB and ODS data import, SYLK import, CSV/TSV, JSON (debug), HTML export. use std::fmt::Write as _; @@ -7,6 +7,8 @@ use gridcraft_model::{Sheet, Workbook}; use crate::{EngineError, Result}; +mod sylk; + /// File formats GridCraft reads or writes. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FileKind { @@ -19,6 +21,8 @@ pub enum FileKind { Tsv, Json, Html, + /// SYLK symbolic link text (import only). + Sylk, } impl FileKind { @@ -33,6 +37,7 @@ impl FileKind { "tsv" | "tab" | "txt" => FileKind::Tsv, "json" | "scjson" => FileKind::Json, "html" | "htm" => FileKind::Html, + "slk" => FileKind::Sylk, _ => return None, }) } @@ -313,6 +318,11 @@ fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, } Ok((wb, warnings)) } + Some(FileKind::Sylk) => { + let (mut wb, warnings) = sylk::read(name, bytes)?; + rename_first_sheet(&mut wb, name); + Ok((wb, warnings)) + } Some(FileKind::Tsv) => { let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', parse_values: !csv_as_text, ..Default::default() }; let (mut wb, report) = gridcraft_xlsx::read_csv_with_report(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?; @@ -486,6 +496,9 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result Err(EngineError::Other("XLSB is supported for data import only. Save as .xlsx or another supported export format.".into())), FileKind::Ods => Err(EngineError::Other("ODS is supported for data import only. Save as .xlsx or another supported export format.".into())), + FileKind::Sylk => { + Err(EngineError::Other("SYLK (.slk) is supported for import only. Save as .xlsx or another supported export format.".into())) + } // A workbook read from CSV keeps the separator it was read with (a `;` file stays `;`). FileKind::Csv => wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).transpose().map(Option::unwrap_or_default), FileKind::Tsv => wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).transpose().map(Option::unwrap_or_default), diff --git a/crates/engine/src/io/sylk.rs b/crates/engine/src/io/sylk.rs new file mode 100644 index 000000000..9d8765783 --- /dev/null +++ b/crates/engine/src/io/sylk.rs @@ -0,0 +1,323 @@ +//! SYLK (`.slk`) import: the symbolic link text format older spreadsheets and some legacy systems +//! export. One record per line, fields separated by `;` (`;;` is a literal semicolon): +//! +//! - `ID` starts the file; `E` ends it. +//! - `C;Y;X;K;E` is a cell. `Y` and `X` may be left out to stay on the +//! current row or column. `K` is a number, `"text"`, `TRUE`/`FALSE` or an error; `E` is a +//! formula in R1C1 notation; `S;R;C` shares the formula of another cell. +//! - `P;P` adds a number format to the file's list; `F;P;Y;X` gives a cell the +//! n-th format (0-based), and `F;W ` sets column widths in characters. +//! +//! Other records (fonts, options, window settings) are skipped. Formulas are converted to A1 with +//! the same relative and absolute references, as Excel does when it opens the file. + +use std::collections::HashMap; +use std::sync::Arc; + +use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, Value, col_to_letters}; +use gridcraft_model::{Cell, Formula, NumFmt, Sheet, Style, Workbook}; + +use crate::{EngineError, Result}; + +/// The largest SYLK file read, as for other text imports: past it the file is refused. +const MAX_BYTES: usize = 512 << 20; + +/// Reads a SYLK file into a one-sheet workbook. Returns the workbook and its warnings. +pub fn read(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec)> { + if bytes.len() > MAX_BYTES { + return Err(EngineError::Other(format!("We can't open '{name}': the SYLK file is larger than {} MB.", MAX_BYTES >> 20))); + } + let text = match std::str::from_utf8(bytes) { + Ok(s) => s.to_string(), + // Older exports are in a single-byte code page: keep each byte as its Latin-1 character. + Err(_) => bytes.iter().map(|&b| char::from(b)).collect(), + }; + let text = text.strip_prefix('\u{feff}').unwrap_or(&text); + if !text.starts_with("ID") { + return Err(EngineError::Other(format!("We can't open '{name}': it isn't a SYLK file (it doesn't start with an ID record)."))); + } + let mut sheet = Sheet::new("Sheet1"); + let mut formats: Vec = Vec::new(); + // Cells' number formats by index into `formats`, applied once every format is known. + let mut cell_formats: Vec<(CellRef, usize)> = Vec::new(); + let mut formulas: HashMap = HashMap::new(); + let mut widths: Vec<(u32, u32, f64)> = Vec::new(); + let (mut row, mut col) = (0u32, 0u32); + let mut skipped = 0usize; + for line in text.lines() { + let fields = split_fields(line); + let Some((kind, rest)) = fields.split_first() else { continue }; + match kind.as_str() { + "C" => { + let (mut value, mut formula, mut shared) = (None, None, None::<(Option, Option)>); + let mut bad = false; + for f in rest { + let (tag, arg) = f.split_at(f.chars().next().map_or(0, char::len_utf8)); + match tag { + "Y" => match position(arg, MAX_ROWS) { + Some(r) => row = r, + None => bad = true, + }, + "X" => match position(arg, MAX_COLS) { + Some(c) => col = c, + None => bad = true, + }, + "K" => value = Some(parse_value(arg)), + "E" => formula = Some(arg.to_string()), + "S" => shared = Some((None, None)), + // In a shared-formula cell, R and C name the cell whose formula it shares. + "R" if shared.is_some() => shared = shared.map(|(_, c)| (position(arg, MAX_ROWS), c)), + "C" if shared.is_some() => shared = shared.map(|(r, _)| (r, position(arg, MAX_COLS))), + _ => {} + } + } + if bad { + skipped += 1; + continue; + } + let at = CellRef::new(row, col); + if let Some((Some(r), Some(c))) = shared { + formula = formulas.get(&CellRef::new(r, c)).cloned(); + } + let mut cell = sheet.cell(at).cloned().unwrap_or_else(|| Cell::value(Value::Empty)); + if let Some(v) = value { + cell.value = v; + } + if let Some(r1c1) = formula { + let a1 = r1c1_formula_to_a1(&r1c1, at); + let f = Formula::new(&a1); + if f.expr.is_some() { + cell.formula = Some(Arc::new(f)); + formulas.insert(at, r1c1); + } else { + skipped += 1; + } + } + sheet.set_cell(at, cell); + } + "F" => { + let mut format = None; + for f in rest { + let (tag, arg) = f.split_at(f.chars().next().map_or(0, char::len_utf8)); + match tag { + "Y" => row = position(arg, MAX_ROWS).unwrap_or(row), + "X" => col = position(arg, MAX_COLS).unwrap_or(col), + "P" => format = arg.parse::().ok(), + "W" => { + let mut parts = arg.split_whitespace(); + if let (Some(first), Some(last), Some(width)) = (parts.next(), parts.next(), parts.next()) + && let (Some(first), Some(last), Ok(width)) = + (position(first, MAX_COLS), position(last, MAX_COLS), width.parse::()) + && first <= last + && (0.0..=255.0).contains(&width) + { + widths.push((first, last, width)); + } + } + _ => {} + } + } + if let Some(format) = format { + cell_formats.push((CellRef::new(row, col), format)); + } + } + // Number formats only: `P;F…` and `P;E…` are fonts. + "P" => { + if let Some(code) = rest.first().and_then(|f| f.strip_prefix('P')) { + formats.push(code.to_string()); + } + } + "E" => break, + _ => {} + } + } + for (first, last, width) in widths { + let size = crate::cmd::format::chars_to_points(width) as f32; + for c in first..=last { + sheet.cols.entry(c).or_default().size = Some(size); + } + } + let mut wb = Workbook::new(); + let mut styles = HashMap::new(); + for (at, index) in cell_formats { + let Some(code) = formats.get(index).filter(|code| !code.is_empty() && *code != "General") else { continue }; + let style = *styles.entry(index).or_insert_with(|| wb.styles.intern(Style { num_fmt: NumFmt(Arc::from(code.as_str())), ..Style::default() })); + let mut cell = sheet.cell(at).cloned().unwrap_or_else(|| Cell::value(Value::Empty)); + cell.style = style; + sheet.set_cell(at, cell); + } + if let Some(first) = wb.sheet_mut(0) { + sheet.name = std::mem::take(&mut first.name); + *first = sheet; + } + let mut warnings = Vec::new(); + if skipped > 0 { + warnings.push(format!("{name}: {skipped} SYLK records GridCraft couldn't read were skipped")); + } + Ok((wb, warnings)) +} + +/// A record's fields: `;` separates them and `;;` is a literal semicolon. +fn split_fields(line: &str) -> Vec { + let mut fields = vec![String::new()]; + let mut chars = line.trim_end_matches('\r').chars().peekable(); + while let Some(c) = chars.next() { + if c == ';' { + if chars.peek() == Some(&';') { + chars.next(); + if let Some(f) = fields.last_mut() { + f.push(';'); + } + } else { + fields.push(String::new()); + } + } else if let Some(f) = fields.last_mut() { + f.push(c); + } + } + fields +} + +/// A 1-based row or column number → 0-based, within `max`. +fn position(arg: &str, max: u32) -> Option { + arg.trim().parse::().ok().filter(|n| (1..=max).contains(n)).map(|n| n - 1) +} + +/// A `K` value: `"text"` (everything between the first and the last quote, as written), a +/// boolean, an error or a number. +fn parse_value(arg: &str) -> Value { + if let Some(text) = arg.strip_prefix('"') { + let text = text.strip_suffix('"').unwrap_or(text); + return Value::Text(Arc::from(text)); + } + if arg.eq_ignore_ascii_case("TRUE") || arg.eq_ignore_ascii_case("FALSE") { + return Value::Bool(arg.eq_ignore_ascii_case("TRUE")); + } + if let Some(e) = CellError::parse(arg) { + return Value::Error(e); + } + match arg.trim().parse::() { + Ok(n) if n.is_finite() => Value::Number(n), + _ => Value::Text(Arc::from(arg)), + } +} + +/// One part of an R1C1 reference: `R`/`C` followed by an absolute number, a bracketed offset or +/// nothing (the cell's own row or column). +#[derive(Clone, Copy, Debug, PartialEq)] +enum Part { + Absolute(u32), + Relative(i64), +} + +impl Part { + /// The 0-based index and whether it is absolute, or `None` when it falls off the sheet. + fn resolve(self, base: u32, max: u32) -> Option<(u32, bool)> { + match self { + Part::Absolute(n) => (1..=max).contains(&n).then(|| (n - 1, true)), + Part::Relative(k) => u32::try_from(i64::from(base) + k).ok().filter(|v| *v < max).map(|v| (v, false)), + } + } +} + +/// Parses the number after `R` or `C` at `i`: digits, `[offset]` or nothing. Returns the part and +/// the index after it, or `None` when the text isn't a reference part. +fn parse_part(chars: &[char], i: usize) -> Option<(Part, usize)> { + match chars.get(i) { + Some('[') => { + let close = chars[i..].iter().position(|&c| c == ']')? + i; + let inner: String = chars[i + 1..close].iter().collect(); + Some((Part::Relative(inner.parse().ok()?), close + 1)) + } + Some(c) if c.is_ascii_digit() => { + let end = chars[i..].iter().position(|c| !c.is_ascii_digit()).map_or(chars.len(), |p| p + i); + let n: String = chars[i..end].iter().collect(); + Some((Part::Absolute(n.parse().unwrap_or(u32::MAX)), end)) + } + _ => Some((Part::Relative(0), i)), + } +} + +fn is_word_char(c: char) -> bool { + c.is_alphanumeric() || matches!(c, '_' | '.' | '\\' | '?' | '$') +} + +/// Rewrites the R1C1 references in a SYLK formula as A1 references for the cell at `at`: +/// `RC[-1]` → `B2`, `R1C1` → `$A$1`, `R[-2]C:R[-1]C` → `D2:D3`, a whole column `C4` → `$D:$D` and +/// a whole row `R3` → `$3:$3`. Text in quotes and quoted sheet names are left alone; a reference +/// off the sheet becomes `#REF!`. +pub(crate) fn r1c1_formula_to_a1(formula: &str, at: CellRef) -> String { + let chars: Vec = formula.chars().collect(); + let mut out = String::with_capacity(formula.len()); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + if c == '"' || c == '\'' { + // Copy the quoted run as it is; a doubled quote stays inside it. + let mut j = i + 1; + while j < chars.len() && !(chars[j] == c && chars.get(j + 1) != Some(&c)) { + j += if chars[j] == c { 2 } else { 1 }; + } + let stop = (j + 1).min(chars.len()); + out.extend(&chars[i..stop]); + i = stop; + continue; + } + let starts_word = i == 0 || !is_word_char(chars[i - 1]); + if starts_word + && matches!(c, 'R' | 'r' | 'C' | 'c') + && let Some((text, next)) = reference_at(&chars, i, at) + { + out.push_str(&text); + i = next; + continue; + } + // Copy the rest of a word as it is, so `ROUND` or `Sheet2` isn't read as a reference. + if is_word_char(c) { + let end = (i..chars.len()).find(|&j| !is_word_char(chars[j])).unwrap_or(chars.len()); + out.extend(&chars[i..end]); + i = end; + } else { + out.push(c); + i += 1; + } + } + out +} + +/// The A1 text of the R1C1 reference starting at `i`, and the index after it, or `None` when the +/// word there isn't a reference (a function or a name). +fn reference_at(chars: &[char], i: usize, at: CellRef) -> Option<(String, usize)> { + let mut j = i; + let mut row = None; + let mut col = None; + if matches!(chars.get(j), Some('R' | 'r')) { + let (part, next) = parse_part(chars, j + 1)?; + row = Some(part); + j = next; + } + if matches!(chars.get(j), Some('C' | 'c')) { + let (part, next) = parse_part(chars, j + 1)?; + col = Some(part); + j = next; + } + if row.is_none() && col.is_none() { + return None; + } + // A reference ends the word: `ROUND(`, `RATE` or `R2D2` are names. + if chars.get(j).is_some_and(|&c| is_word_char(c) || c == '(' || c == '[' || c == '!') { + return None; + } + let row_text = |p: Part| p.resolve(at.row, MAX_ROWS).map(|(r, abs)| format!("{}{}", if abs { "$" } else { "" }, r + 1)); + let col_text = |p: Part| p.resolve(at.col, MAX_COLS).map(|(c, abs)| format!("{}{}", if abs { "$" } else { "" }, col_to_letters(c))); + // One side of a range (`R2:R4`, `C1:C3`) is written alone; a lone row or column is the whole of it. + let prev = chars[..i].iter().rev().find(|c| !c.is_whitespace()); + let in_range = prev == Some(&':') || chars[j..].iter().find(|c| !c.is_whitespace()) == Some(&':'); + let text = match (row, col) { + (Some(r), Some(c)) => row_text(r).zip(col_text(c)).map(|(r, c)| format!("{c}{r}")), + (Some(r), None) => row_text(r).map(|r| if in_range { r } else { format!("{r}:{r}") }), + (None, Some(c)) => col_text(c).map(|c| if in_range { c } else { format!("{c}:{c}") }), + (None, None) => None, + }; + Some((text.unwrap_or_else(|| "#REF!".into()), j)) +} diff --git a/crates/engine/src/io/tests.rs b/crates/engine/src/io/tests.rs index ad3fae99c..0e93be9ee 100644 --- a/crates/engine/src/io/tests.rs +++ b/crates/engine/src/io/tests.rs @@ -668,3 +668,81 @@ fn a_web_page_table_opens_after_a_leading_comment_and_keeps_nonbreaking_spaces() assert_eq!(sh.value(CellRef::parse("A1").unwrap()), gridcraft_core::Value::text("New\u{a0}\u{a0}York")); assert_eq!(sh.value(CellRef::parse("B1").unwrap()), gridcraft_core::Value::text("a b")); } + +/// A small SYLK file written the way spreadsheets export one: a header, number formats, column +/// widths, cells that carry on from the last row or column, formulas in R1C1 and a shared formula. +const SYLK: &str = "ID;PTEST;N;E\r\nP;PGeneral\r\nP;P0.00\r\nP;Pyyyy\\-mm\\-dd\r\nF;W1 1 18\r\nF;W2 3 9\r\nB;Y5;X7\r\n\ +C;Y1;X1;K\"Item\"\r\nC;X2;K\"Qty\"\r\nC;X4;K\"Total\"\r\n\ +C;Y2;X1;K\"Bolts;; small\"\r\nC;X2;K12\r\nF;P1;F$2G;X3\r\nC;X3;K0.35\r\nC;X4;K4.2;ERC[-2]*RC[-1]\r\n\ +C;X5;K52;ER2C2+R[1]C[-3]\r\nC;X6;KTRUE\r\nC;X7;K8.4;ERC[-3]*2\r\n\ +C;Y3;X1;K\"Nuts \"\"hex\"\"\"\r\nC;X2;K40\r\nC;X3;K0.12\r\nC;X4;K4.8;ERC[-2]*RC[-1]\r\nC;X5;K\"big\";EIF(R4C4>5,\"big\",\"small\")\r\nC;X7;K9.6;S;R2;C7\r\n\ +C;Y4;X1;K\"Sum\"\r\nC;X4;K9;ESUM(R[-2]C:R[-1]C)\r\nC;X5;K#DIV/0!;E1/0\r\nC;X7;K18;ESUM(C4)\r\n\ +F;P2;FG0G;Y5;X2\r\nC;Y5;X2;K45000\r\nC;X3;K\"R2C3\";E\"R2C3\"\r\nE\r\n"; + +#[test] +fn sylk_files_open_with_values_formulas_formats_and_widths() { + let (wb, warnings) = open_bytes("Parts list.slk", SYLK.as_bytes()).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let sh = wb.sheet(0).unwrap(); + assert_eq!(sh.name, "Parts list", "the sheet is named after the file"); + let at = |a: &str| CellRef::parse(a).unwrap(); + let formula = |a: &str| sh.cell(at(a)).and_then(|c| c.formula.as_ref()).map(|f| f.text.clone()); + let text = |s: &str| gridcraft_core::Value::Text(s.into()); + assert_eq!(sh.value(at("A1")), text("Item")); + assert_eq!(sh.value(at("A2")), text("Bolts; small"), ";; is a semicolon"); + assert_eq!(sh.value(at("A3")), text("Nuts \"\"hex\"\""), "text is kept between the outer quotes as written"); + assert_eq!(sh.value(at("B2")), gridcraft_core::Value::Number(12.0)); + assert_eq!(sh.value(at("F2")), gridcraft_core::Value::Bool(true)); + assert_eq!(sh.value(at("E4")), gridcraft_core::Value::Error(gridcraft_core::CellError::Div0)); + assert_eq!(sh.value(at("D4")), gridcraft_core::Value::Number(9.0), "a formula keeps the value the file cached"); + assert_eq!(formula("D2").as_deref(), Some("B2*C2")); + assert_eq!(formula("E2").as_deref(), Some("$B$2+B3")); + assert_eq!(formula("E3").as_deref(), Some("IF($D$4>5,\"big\",\"small\")")); + assert_eq!(formula("D4").as_deref(), Some("SUM(D2:D3)")); + assert_eq!(formula("G3").as_deref(), Some("D3*2"), "a shared formula is the other cell's, moved"); + assert_eq!(formula("G4").as_deref(), Some("SUM($D:$D)"), "C4 alone is the whole column"); + assert_eq!(formula("C5").as_deref(), Some("\"R2C3\""), "text in quotes isn't a reference"); + // `F;P1;X3` formats the cell on the current row only. + let fmt = |a: &str| wb.styles.get(sh.style_id(at(a))).num_fmt.0.to_string(); + assert_eq!(fmt("C2"), "0.00"); + assert_eq!(fmt("C3"), "General"); + assert_eq!(fmt("B5"), "yyyy\\-mm\\-dd"); + // Widths are in characters, like the Column Width dialog's. + let chars = |c: u32| gridcraft_model::points_to_chars(f64::from(sh.col_width(c))); + assert_eq!((chars(0), chars(1), chars(2)), (18.0, 9.0, 9.0)); + assert_eq!(sh.cols.get(&3).and_then(|i| i.size), None, "column D keeps the default width"); +} + +#[test] +fn sylk_r1c1_formulas_become_a1() { + let b2 = CellRef::parse("B2").unwrap(); + for (r1c1, a1) in [ + ("RC", "B2"), + ("R[1]C[-1]", "A3"), + ("R1C1:R2C2", "$A$1:$B$2"), + ("R3", "$3:$3"), + ("R1:R[2]", "$1:4"), + ("C[1]", "C:C"), + ("SUM(C3:C4)", "SUM($C:$D)"), + ("ROUND(RC[1],2)+RATE(1,2,3)", "ROUND(C2,2)+RATE(1,2,3)"), + ("'R1C1 sheet'!R1C1", "'R1C1 sheet'!$A$1"), + ("Sheet2!RC", "Sheet2!B2"), + ("R2D2+rc", "R2D2+B2"), + ("R[-5]C", "#REF!"), + ("\"RC\"&R1C1", "\"RC\"&$A$1"), + ] { + assert_eq!(sylk::r1c1_formula_to_a1(r1c1, b2), a1, "{r1c1}"); + } +} + +#[test] +fn sylk_rejects_other_files_and_survives_damage() { + assert!(open_bytes("not.slk", b"A,B\n1,2\n").is_err(), "a SYLK file starts with ID"); + // Positions off the sheet, broken numbers, a missing end and a format past the list. + let (wb, warnings) = + open_bytes("damaged.slk", b"ID;P\nC;Y0;X1;K1\nC;Y2;X99999;K2\nC;Y3;X1;K1e999\nF;P7;Y3;X1\nF;W5 2 9\nC;Y4;X1;K5;E(((\n").unwrap(); + let sh = wb.sheet(0).unwrap(); + assert_eq!(sh.cells.iter().count(), 2, "{:?}", sh.cells.iter().collect::>()); + assert_eq!(sh.value(CellRef::parse("A3").unwrap()), gridcraft_core::Value::Text("1e999".into())); + assert_eq!(warnings.len(), 1, "skipped records are reported: {warnings:?}"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 4442ce315..d2660dde0 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -106,6 +106,9 @@ past the parser's limit, and a wrapper inside the text. **Saving writes the real | `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, likely Windows-1256 Arabic detection, otherwise Windows-1252 fallback; delimiter detection; per-column day/month date-order detection for `/` and `-` dates) | yes (UTF-8 without BOM, CRLF) | Writes the **displayed** values like Excel (number formats applied, formula results, never column-width `#####`); `file.exportCsv {values: true}` writes raw values instead. No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). | | `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`; likely Windows-1256 Arabic detection for invalid UTF-8; per-column day/month date-order detection for `/` and `-` dates) | yes (displayed values) | Text Import Wizard options partly via Text to Columns | | `.prn` formatted text (space-delimited) | R/W | no | no | | +| `.slk` SYLK | R/W | **yes, partial** (#1481) | no (Save As `.xlsx`) | Values (numbers, text, booleans, errors), formulas converted from R1C1 to A1 as Excel does (relative and absolute references, ranges, whole rows and columns, shared formulas) with their cached results, number formats (`P`/`F;P`), column widths (`F;W`); the sheet is named after the file. Not read: fonts and cell styles, row heights, names, comments. Non-UTF-8 files are read as Latin-1 | engine `io/tests.rs` (`sylk_*`) | +| `.dif` Data Interchange Format | R/W | no | no | Small, legacy | +| `.htm` / `.html` web page | R/W (Windows) | yes, via Get Data ▸ From HTML | yes (one sheet as a table; a used range taller than 100,001 rows fails with an error instead of being cut short) | Formatting not exported. A sheet with pictures or charts is written with the sheet's own column widths and row heights, a picture as a `data:` URI `` and a chart as inline SVG, each placed at its anchor; shapes, text boxes and comments are not exported | | `.dif`, `.slk` (SYLK) | R/W | no | no | Small, legacy | | `.htm` / `.html` web page | R/W (Windows) | yes, via Get Data ▸ From HTML | yes: opening reads the page's first table as a sheet (header cells bold and centred, values read as typed, the sheet named by ``), also when the page is saved as `.xls`/`.xlsx` as web apps' "Export to Excel" does, with a warning that the format and name don't match (#3166); saving writes one sheet as a table; a used range taller than 100,001 rows fails with an error instead of being cut short) | Formatting not exported. A sheet with pictures or charts is written with the sheet's own column widths and row heights, a picture as a `data:` URI `` and a chart as inline SVG, each placed at its anchor; shapes, text boxes and comments are not exported | | `.mht` / `.mhtml` single-file web page | R/W (Windows) | no | no | | @@ -148,13 +151,14 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Legacy `.xls` (BIFF8): exact theme colours (XFExt), comments, CF, validation, pictures and charts on import; corpus run; then write | 10–15 | | Encrypted workbooks: save with password (#11) | 4–6 | | XLSB and ODS: formulas and formatting on import; then write | 10–15 | -| CSV/text: encoding choice, `.prn`, `.dif`, `.slk`, XML Spreadsheet 2003 | 4–7 | +| CSV/text: encoding choice, `.prn`, `.dif`, XML Spreadsheet 2003 | 4–7 | | PDF: embed subsetted system fonts so every script exports | (counted in [localization](localization-parity.md) script work) | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | SYLK (`.slk`) import: values, R1C1 formulas as A1 (shared formulas too) with cached results, number formats and column widths; import only (#1481) | | 2026-11-03 | minor | Opening a web page (HTML) reads its first table as a sheet, whatever the file is called: a web app's "Export to Excel" saved as `.xls` no longer opens as lines of markup. Values read as in a CSV (or as text with `csvAsText`), `` cells are bold and centred, `` names the sheet, and a page not named `.htm`/`.html` opens with a warning (#3166) | | 2026-11-03 | minor | XLSX `` (Remove personal information from file properties on save) is read and written back, and while it is on GridCraft saves no author (`dc:creator`) and no company, as Excel leaves them empty (#3089, file part) | | 2026-11-03 | minor | XLSX `` (a workbook saved in R1C1 reference style) is read into the calculation settings and written back; GridCraft itself still shows A1 references (#2809, file part) | diff --git a/docs/gaps.md b/docs/gaps.md index de725fb4c..8879e967d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -157,7 +157,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- [ui-parity.md](ui-parity.md#accessibility) ### 19. XLSB/ODS beyond values; minor formats (FF) -- XLSB and ODS import values only, no write; no `.prn`, `.dif`, `.slk`, XML Spreadsheet 2003; CSV +- XLSB and ODS import values only, no write; no `.prn`, `.dif`, XML Spreadsheet 2003 (`.slk` imports values, formulas, number formats and widths); CSV writes raw instead of displayed values; templates saved with the workbook content type. **Estimate:** 15–23 h.