mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-12 08:53:03 -06:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b7e4f00dc | ||
|
|
4deedbb549 | ||
|
|
26e7112e0f | ||
|
|
3d31729de3 | ||
|
|
aeb3fb19ce | ||
|
|
e4ec0d7ed1 | ||
|
|
23487b0cb5 | ||
|
|
0407b63663 | ||
|
|
1bd4ca8946 | ||
|
|
0431741359 | ||
|
|
c1adf9b239 | ||
|
|
21df49c636 | ||
|
|
7e0d96d74b |
Generated
+52
-8
@@ -907,15 +907,28 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-cfb"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
|
||||
[[package]]
|
||||
name = "craft-csv"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=119415f46f601e20b9bc6b1b4d6979ceda86d88e#119415f46f601e20b9bc6b1b4d6979ceda86d88e"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=119415f46f601e20b9bc6b1b4d6979ceda86d88e#119415f46f601e20b9bc6b1b4d6979ceda86d88e"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
@@ -938,7 +951,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=119415f46f601e20b9bc6b1b4d6979ceda86d88e#119415f46f601e20b9bc6b1b4d6979ceda86d88e"
|
||||
dependencies = [
|
||||
"craft-winprint-gdi",
|
||||
"log",
|
||||
@@ -949,10 +962,15 @@ dependencies = [
|
||||
"winsafe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-sheet"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=119415f46f601e20b9bc6b1b4d6979ceda86d88e#119415f46f601e20b9bc6b1b4d6979ceda86d88e"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
@@ -972,7 +990,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -999,7 +1017,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-web-api"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=119415f46f601e20b9bc6b1b4d6979ceda86d88e#119415f46f601e20b9bc6b1b4d6979ceda86d88e"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde_json",
|
||||
@@ -1010,7 +1028,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-web-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=119415f46f601e20b9bc6b1b4d6979ceda86d88e#119415f46f601e20b9bc6b1b4d6979ceda86d88e"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
@@ -1019,7 +1037,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"objc2 0.6.5",
|
||||
@@ -1035,6 +1053,19 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-xls"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
dependencies = [
|
||||
"craft-cfb",
|
||||
"craft-sheet",
|
||||
"md-5",
|
||||
"miniz_oxide 0.9.1",
|
||||
"sha1 0.10.7",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -2100,6 +2131,7 @@ dependencies = [
|
||||
name = "gridcraft-engine"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"craft-csv",
|
||||
"gridcraft-calc",
|
||||
"gridcraft-chart",
|
||||
"gridcraft-core",
|
||||
@@ -2241,6 +2273,8 @@ name = "gridcraft-xlsx"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"craft-csv",
|
||||
"craft-xls",
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
"gridcraft-functions",
|
||||
@@ -2992,6 +3026,16 @@ version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
|
||||
+10
-4
@@ -26,10 +26,16 @@ gridcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
gridcraft-mcp = { path = "crates/mcp" }
|
||||
gridcraft-pdf = { path = "crates/pdf" }
|
||||
gridcraft-math = { path = "crates/math" }
|
||||
# Every craft-libs crate (craft-update and craft-window here, craft-ui in ui-egui, craft-print in
|
||||
# the app) comes from one craft-libs revision.
|
||||
# Every craft-libs crate (craft-update, craft-window and the file-format crates here, craft-ui in
|
||||
# ui-egui, craft-print in the app, craft-web-api and craft-web-host in the web app) comes from one
|
||||
# craft-libs revision.
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", version = "=0.1.0", features = ["egui"] }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "=0.1.0", features = ["egui"] }
|
||||
# Delimited text (CSV, TSV) and SYLK; `crates/xlsx/src/csv.rs` and `crates/engine/src/io/sylk.rs`
|
||||
# turn them into workbooks.
|
||||
craft-csv = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "0.1.0" }
|
||||
# Excel 97-2003 (.xls, BIFF8) workbooks; `crates/xlsx/src/xls.rs` maps them onto the model.
|
||||
craft-xls = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "0.1.0" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
@@ -72,7 +78,7 @@ skrifa = { version = "0.44", default-features = false, features = ["std"] }
|
||||
epaint_default_fonts = "0.37"
|
||||
# One native window per workbook: the window list, Window menu titles and the immediate-viewport
|
||||
# driver. Same craft-libs revision as craft-update above.
|
||||
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", default-features = false, features = ["egui"] }
|
||||
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", default-features = false, features = ["egui"] }
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
@@ -18,8 +18,8 @@ gridcraft-engine = { workspace = true }
|
||||
gridcraft-ui-egui = { workspace = true }
|
||||
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
|
||||
# The page command API (storytold/craft-libs#413): window.gridcraft over the control channel.
|
||||
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "119415f46f601e20b9bc6b1b4d6979ceda86d88e", version = "0.1.0" }
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "119415f46f601e20b9bc6b1b4d6979ceda86d88e", version = "0.1.0" }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -35,7 +35,7 @@ winit = { version = "0.30", default-features = false, features = ["x11", "waylan
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
# Same craft-libs revision as the workspace's craft-update and craft-window (see the root
|
||||
# Cargo.toml).
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "119415f46f601e20b9bc6b1b4d6979ceda86d88e" }
|
||||
craft-update = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
arboard = { workspace = true, features = ["image-data"] }
|
||||
|
||||
@@ -21,6 +21,7 @@ web-time = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
gridcraft-xlsx = { workspace = true }
|
||||
craft-csv = { workspace = true }
|
||||
gridcraft-chart = { workspace = true }
|
||||
gridcraft-pdf = { workspace = true }
|
||||
gridcraft-math = { workspace = true }
|
||||
|
||||
+37
-301
@@ -1,156 +1,57 @@
|
||||
//! 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<row>;X<col>;K<value>;E<formula>` 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<row>;C<col>` shares the formula of another cell.
|
||||
//! - `P;P<code>` adds a number format to the file's list; `F;P<n>;Y<row>;X<col>` gives a cell the
|
||||
//! n-th format (0-based), and `F;W<first> <last> <width>` 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.
|
||||
//! SYLK (`.slk`) import. `craft_csv::sylk` reads the file (values, formulas rewritten from R1C1 to
|
||||
//! A1, number formats, column widths); this module makes it a one-sheet workbook: formulas parsed
|
||||
//! (a formula GridCraft can't parse is skipped and counted), error values recognised, formats
|
||||
//! interned as styles and widths converted from characters.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, Value, col_to_letters};
|
||||
use craft_csv::sylk;
|
||||
use gridcraft_core::{CellError, CellRef, Value};
|
||||
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;
|
||||
/// Column-width records kept from one file; each can cover every column.
|
||||
const MAX_WIDTH_RECORDS: usize = 1024;
|
||||
|
||||
/// 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<String>)> {
|
||||
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 file = sylk::read(bytes).map_err(|e| match e {
|
||||
sylk::Error::TooLarge => EngineError::Other(format!("We can't open '{name}': the SYLK file is larger than {} MB.", sylk::MAX_BYTES >> 20)),
|
||||
_ => EngineError::Other(format!("We can't open '{name}': it isn't a SYLK file (it doesn't start with an ID record).")),
|
||||
})?;
|
||||
let mut wb = Workbook::new();
|
||||
let mut sheet = Sheet::new("Sheet1");
|
||||
let mut formats: Vec<String> = 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<CellRef, String> = 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<u32>, Option<u32>)>);
|
||||
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);
|
||||
let mut skipped = file.skipped;
|
||||
let mut styles = HashMap::new();
|
||||
for (&(row, col), c) in &file.cells {
|
||||
let value = match &c.value {
|
||||
sylk::Value::Number(n) => Value::Number(*n),
|
||||
sylk::Value::Text(s) => Value::Text(Arc::from(s.as_str())),
|
||||
sylk::Value::Bool(b) => Value::Bool(*b),
|
||||
sylk::Value::Error(s) => CellError::parse(s).map_or_else(|| Value::Text(Arc::from(s.as_str())), Value::Error),
|
||||
_ => Value::Empty,
|
||||
};
|
||||
let mut cell = Cell::value(value);
|
||||
if let Some(a1) = &c.formula {
|
||||
let f = Formula::new(a1);
|
||||
if f.expr.is_some() {
|
||||
cell.formula = Some(Arc::new(f));
|
||||
} else {
|
||||
skipped += 1;
|
||||
}
|
||||
"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::<usize>().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::<f64>())
|
||||
&& first <= last
|
||||
&& (0.0..=255.0).contains(&width)
|
||||
// A file of width records can't make the import loop over every column
|
||||
// again and again.
|
||||
&& widths.len() < MAX_WIDTH_RECORDS
|
||||
{
|
||||
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,
|
||||
_ => {}
|
||||
}
|
||||
if let Some(code) = &c.format {
|
||||
cell.style = *styles
|
||||
.entry(code.as_str())
|
||||
.or_insert_with(|| wb.styles.intern(Style { num_fmt: NumFmt(Arc::from(code.as_str())), ..Style::default() }));
|
||||
}
|
||||
sheet.set_cell(CellRef::new(row, col), cell);
|
||||
}
|
||||
for (first, last, width) in widths {
|
||||
let size = crate::cmd::format::chars_to_points(width) as f32;
|
||||
for c in first..=last {
|
||||
for w in &file.column_widths {
|
||||
let size = crate::cmd::format::chars_to_points(w.width) as f32;
|
||||
for c in w.first..=w.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;
|
||||
@@ -161,168 +62,3 @@ pub fn read(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
|
||||
}
|
||||
Ok((wb, warnings))
|
||||
}
|
||||
|
||||
/// A record's fields: `;` separates them and `;;` is a literal semicolon.
|
||||
fn split_fields(line: &str) -> Vec<String> {
|
||||
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<u32> {
|
||||
arg.trim().parse::<u32>().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::<f64>() {
|
||||
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) => i64::from(base).checked_add(k).and_then(|v| u32::try_from(v).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<char> = 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))
|
||||
}
|
||||
|
||||
@@ -763,7 +763,7 @@ fn sylk_r1c1_formulas_become_a1() {
|
||||
("R[-5]C", "#REF!"),
|
||||
("\"RC\"&R1C1", "\"RC\"&$A$1"),
|
||||
] {
|
||||
assert_eq!(sylk::r1c1_formula_to_a1(r1c1, b2), a1, "{r1c1}");
|
||||
assert_eq!(craft_csv::sylk::r1c1_formula_to_a1(r1c1, (b2.row, b2.col)), a1, "{r1c1}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3612,6 +3612,8 @@ fn xls_fixture() -> Vec<u8> {
|
||||
let mut put = |at: usize, v: &[u8]| b[at..at + v.len()].copy_from_slice(v);
|
||||
put(0, &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]);
|
||||
put(26, &3u16.to_le_bytes());
|
||||
// The byte order mark [MS-CFB] 2.2 requires; craft-cfb refuses a header without it.
|
||||
put(28, &0xFFFEu16.to_le_bytes());
|
||||
put(30, &9u16.to_le_bytes());
|
||||
put(32, &6u16.to_le_bytes());
|
||||
put(44, &1u32.to_le_bytes());
|
||||
|
||||
@@ -11,7 +11,7 @@ workspace = true
|
||||
[dependencies]
|
||||
# The shared capture slot (`craft_ui::capture`) comes from the one craft-libs revision every
|
||||
# craft-libs dependency uses (see the root Cargo.toml).
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "119415f46f601e20b9bc6b1b4d6979ceda86d88e" }
|
||||
gridcraft-engine = { workspace = true }
|
||||
gridcraft-chart = { workspace = true }
|
||||
# `monochrome_emoji_fonts` restores the Noto Emoji and emoji-icon faces that 0.37 moved behind
|
||||
|
||||
@@ -18,6 +18,8 @@ gridcraft-core = { workspace = true }
|
||||
gridcraft-formula = { workspace = true }
|
||||
gridcraft-math = { workspace = true }
|
||||
gridcraft-model = { workspace = true }
|
||||
craft-csv = { workspace = true }
|
||||
craft-xls = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
quick-xml = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
@@ -298,11 +298,6 @@ impl<'a> Compound<'a> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Whether the root storage holds a stream called `name`, without reading it.
|
||||
pub(crate) fn has_root_stream(&self, name: &str) -> bool {
|
||||
matches!(self.root_entry(name), Ok(Some(_)))
|
||||
}
|
||||
|
||||
/// The contents of the stream called `name` (case-insensitive) directly under the root
|
||||
/// storage, or `None` when there is no such stream.
|
||||
pub(crate) fn root_stream(&self, name: &str) -> Result<Option<Vec<u8>>, String> {
|
||||
|
||||
+10
-251
@@ -1,4 +1,6 @@
|
||||
//! CSV import and export (RFC 4180 with the usual real-world leniency).
|
||||
//! CSV import and export (RFC 4180 with the usual real-world leniency). Decoding, delimiter
|
||||
//! sniffing, the record parser and field quoting are `craft-csv`'s; this module turns records into
|
||||
//! typed cells (with each column's date order) and writes a sheet.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -12,241 +14,11 @@ use crate::{CsvOptions, IoError, ReadReport};
|
||||
/// The largest CSV or tab-separated file written (bytes).
|
||||
pub const MAX_CSV_BYTES: usize = 512 << 20;
|
||||
|
||||
/// Windows-1252 code points for bytes 0x80–0x9F (others map to Latin-1).
|
||||
const CP1252: [u32; 32] = [
|
||||
0x20AC, 0x81, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x8D, 0x017D, 0x8F, 0x90, 0x2018, 0x2019,
|
||||
0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x9D, 0x017E, 0x0178,
|
||||
];
|
||||
pub use craft_csv::{decode_text, sniff_delimiter};
|
||||
|
||||
/// Windows-1256 code points for bytes 0x80–0xFF (unicode.org `CP1256.TXT`).
|
||||
pub(crate) const CP1256: [u32; 128] = [
|
||||
0x20AC, 0x067E, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0679, 0x2039, 0x0152, 0x0686, 0x0698, 0x0688, 0x06AF, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x06A9, 0x2122, 0x0691, 0x203A, 0x0153, 0x200C, 0x200D, 0x06BA, 0x00A0, 0x060C, 0x00A2, 0x00A3,
|
||||
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x06BE, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x061B, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x061F, 0x06C1, 0x0621, 0x0622, 0x0623, 0x0624, 0x0625, 0x0626, 0x0627,
|
||||
0x0628, 0x0629, 0x062A, 0x062B, 0x062C, 0x062D, 0x062E, 0x062F, 0x0630, 0x0631, 0x0632, 0x0633, 0x0634, 0x0635, 0x0636, 0x00D7, 0x0637, 0x0638,
|
||||
0x0639, 0x063A, 0x0640, 0x0641, 0x0642, 0x0643, 0x00E0, 0x0644, 0x00E2, 0x0645, 0x0646, 0x0647, 0x0648, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
|
||||
0x0649, 0x064A, 0x00EE, 0x00EF, 0x064B, 0x064C, 0x064D, 0x064E, 0x00F4, 0x064F, 0x0650, 0x00F7, 0x0651, 0x00F9, 0x0652, 0x00FB, 0x00FC, 0x200E,
|
||||
0x200F, 0x06D2,
|
||||
];
|
||||
|
||||
/// Evidence that bytes which aren't UTF-8 are Windows-1256 Arabic rather than Windows-1252.
|
||||
#[derive(Default)]
|
||||
struct Cp1256Evidence {
|
||||
/// High bytes that decode to different characters in the two code pages, apart from invisible
|
||||
/// marks and ordinals after a digit.
|
||||
differing: usize,
|
||||
/// Of those, Arabic letters and Arabic punctuation (، ؛ ؟) in Windows-1256.
|
||||
arabic: usize,
|
||||
/// Arabic bytes next to an ASCII letter: Western accents sit inside Latin words, Arabic doesn't.
|
||||
arabic_touching_latin: usize,
|
||||
/// Accented Latin bytes the two code pages share, next to another high byte: Hebrew
|
||||
/// Windows-1255 letters land there, Arabic and Western text rarely do.
|
||||
shared_letters_in_runs: usize,
|
||||
}
|
||||
|
||||
impl Cp1256Evidence {
|
||||
fn of(bytes: &[u8]) -> Self {
|
||||
let mut e = Self::default();
|
||||
for (i, &b) in bytes.iter().enumerate() {
|
||||
if b < 0x80 {
|
||||
continue;
|
||||
}
|
||||
let before = i.checked_sub(1).and_then(|j| bytes.get(j)).copied();
|
||||
let after = bytes.get(i + 1).copied();
|
||||
let next_to = |pred: fn(u8) -> bool| before.is_some_and(pred) || after.is_some_and(pred);
|
||||
if same_in_cp1252_and_cp1256(b) {
|
||||
if is_shared_latin_letter(b) && next_to(|x| x >= 0x80) {
|
||||
e.shared_letters_in_runs += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if is_invisible_mark(b) || is_ordinal_after_digit(b, before) {
|
||||
continue;
|
||||
}
|
||||
e.differing += 1;
|
||||
if is_cp1256_arabic(b) {
|
||||
e.arabic += 1;
|
||||
if next_to(|x| x.is_ascii_alphabetic()) {
|
||||
e.arabic_touching_latin += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
e
|
||||
}
|
||||
|
||||
/// Thresholds chosen against month, day and language names from 95 glibc locales (Western in
|
||||
/// Windows-1252, Arabic, Persian and Urdu in 1256, Hebrew and Yiddish in 1255) plus CSV samples.
|
||||
fn is_arabic(&self) -> bool {
|
||||
self.arabic >= 2
|
||||
&& self.arabic * 10 >= self.differing * 9
|
||||
&& self.arabic_touching_latin * 10 <= self.arabic * 3
|
||||
&& self.shared_letters_in_runs * 20 <= self.arabic
|
||||
}
|
||||
}
|
||||
|
||||
fn same_in_cp1252_and_cp1256(b: u8) -> bool {
|
||||
matches!(
|
||||
b,
|
||||
0x80 | 0x82..=0x89 | 0x8B..=0x8C | 0x91..=0x97 | 0x99 | 0x9B..=0x9C | 0xA0 | 0xA2..=0xA9 | 0xAB..=0xB9 | 0xBB..=0xBE | 0xD7 | 0xE0 | 0xE2 | 0xE7..=0xEB
|
||||
| 0xEE..=0xEF | 0xF4 | 0xF7 | 0xF9 | 0xFB..=0xFC
|
||||
)
|
||||
}
|
||||
|
||||
/// ZWNJ, ZWJ, LRM and RLM: common inside Persian words and around Arabic cells, but no evidence
|
||||
/// either way.
|
||||
fn is_invisible_mark(b: u8) -> bool {
|
||||
matches!(b, 0x9D | 0x9E | 0xFD | 0xFE)
|
||||
}
|
||||
|
||||
/// `º` and `ª` after a digit (`1º`, `2ª`) are Western ordinals, though Windows-1256 reads the bytes
|
||||
/// as `؛` and `ھ`; either way they are no evidence.
|
||||
fn is_ordinal_after_digit(b: u8, before: Option<u8>) -> bool {
|
||||
matches!(b, 0xAA | 0xBA) && before.is_some_and(|x| x.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn is_shared_latin_letter(b: u8) -> bool {
|
||||
matches!(b, 0xE0 | 0xE2 | 0xE7..=0xEB | 0xEE..=0xEF | 0xF4 | 0xF9 | 0xFB..=0xFC)
|
||||
}
|
||||
|
||||
fn is_cp1256_arabic(b: u8) -> bool {
|
||||
matches!(
|
||||
b,
|
||||
0x81 | 0x8A | 0x8D..=0x90 | 0x98 | 0x9A | 0x9F | 0xA1 | 0xAA | 0xBA | 0xBF..=0xD6 | 0xD8..=0xDB | 0xDD..=0xDF | 0xE1 | 0xE3..=0xE6 | 0xEC..=0xED | 0xFF
|
||||
)
|
||||
}
|
||||
|
||||
/// Decodes CSV bytes with BOM-aware UTF-8 and UTF-16 handling. Without a BOM, valid UTF-8 is kept.
|
||||
/// Other bytes decode as Windows-1256 when they read as Arabic (see [`Cp1256Evidence`]), else as
|
||||
/// Windows-1252.
|
||||
pub fn decode_text(bytes: &[u8]) -> String {
|
||||
if let Some(rest) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
|
||||
return String::from_utf8_lossy(rest).into_owned();
|
||||
}
|
||||
let utf16 = |b: &[u8], le: bool| -> String {
|
||||
let units = b.as_chunks::<2>().0.iter().map(|c| if le { u16::from_le_bytes(*c) } else { u16::from_be_bytes(*c) });
|
||||
char::decode_utf16(units).map(|r| r.unwrap_or('\u{FFFD}')).collect()
|
||||
};
|
||||
if let Some(rest) = bytes.strip_prefix(&[0xFF, 0xFE]) {
|
||||
return utf16(rest, true);
|
||||
}
|
||||
if let Some(rest) = bytes.strip_prefix(&[0xFE, 0xFF]) {
|
||||
return utf16(rest, false);
|
||||
}
|
||||
match std::str::from_utf8(bytes) {
|
||||
Ok(s) => s.to_string(),
|
||||
Err(_) => {
|
||||
let use_windows_1256 = Cp1256Evidence::of(bytes).is_arabic();
|
||||
bytes
|
||||
.iter()
|
||||
.map(|&b| match b {
|
||||
0x80..=0xFF if use_windows_1256 => {
|
||||
char::from_u32(CP1256.get((b - 0x80) as usize).copied().unwrap_or(0xFFFD)).unwrap_or('\u{FFFD}')
|
||||
}
|
||||
0x80..=0x9F => char::from_u32(CP1252.get((b - 0x80) as usize).copied().unwrap_or(0xFFFD)).unwrap_or('\u{FFFD}'),
|
||||
_ => b as char,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Picks comma, semicolon or tab: the candidate with the most consistent count per line
|
||||
/// (outside quotes) over the first non-blank lines. A candidate must appear on every such line, otherwise comma wins.
|
||||
pub fn sniff_delimiter(text: &str, quote: char) -> u8 {
|
||||
let mut best = (b',', 0usize, 0usize);
|
||||
for d in [',', ';', '\t'] {
|
||||
let mut counts = vec![];
|
||||
let mut n = 0usize;
|
||||
let mut in_q = false;
|
||||
let mut blank = true;
|
||||
for ch in text.chars().take(64 * 1024) {
|
||||
if !in_q && ch == '\n' {
|
||||
// Blank lines carry no delimiter evidence.
|
||||
if !blank {
|
||||
counts.push(n);
|
||||
}
|
||||
n = 0;
|
||||
blank = true;
|
||||
if counts.len() >= 20 {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ch != '\r' {
|
||||
blank = false;
|
||||
}
|
||||
if ch == quote {
|
||||
in_q = !in_q;
|
||||
} else if !in_q && ch == d {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
if !blank {
|
||||
counts.push(n);
|
||||
}
|
||||
let min = counts.iter().copied().min().unwrap_or(0);
|
||||
let total: usize = counts.iter().sum();
|
||||
if min > 0 && (min, total) > (best.1, best.2) {
|
||||
best = (d as u8, min, total);
|
||||
}
|
||||
}
|
||||
best.0
|
||||
}
|
||||
|
||||
/// Splits CSV text into records of fields. A record keeps at most [`MAX_COLS`] fields; the rest are
|
||||
/// dropped as they are read, so a row of endless delimiters cannot allocate a string per field.
|
||||
pub(crate) fn parse_records(text: &str, delim: char, quote: char, mut on_record: impl FnMut(Vec<String>) -> bool) {
|
||||
let mut field = String::new();
|
||||
let mut record: Vec<String> = vec![];
|
||||
let mut in_q = false;
|
||||
let mut chars = text.chars().peekable();
|
||||
let mut field_started_quoted = false;
|
||||
while let Some(c) = chars.next() {
|
||||
if in_q {
|
||||
if c == quote {
|
||||
if chars.peek() == Some("e) {
|
||||
field.push(quote);
|
||||
chars.next();
|
||||
} else {
|
||||
in_q = false;
|
||||
}
|
||||
} else {
|
||||
field.push(c);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if c == quote && field.is_empty() && !field_started_quoted {
|
||||
in_q = true;
|
||||
field_started_quoted = true;
|
||||
} else if c == delim {
|
||||
let f = std::mem::take(&mut field);
|
||||
if record.len() < MAX_COLS as usize {
|
||||
record.push(f);
|
||||
}
|
||||
field_started_quoted = false;
|
||||
} else if c == '\r' || c == '\n' {
|
||||
if c == '\r' && chars.peek() == Some(&'\n') {
|
||||
chars.next();
|
||||
}
|
||||
let f = std::mem::take(&mut field);
|
||||
if record.len() < MAX_COLS as usize {
|
||||
record.push(f);
|
||||
}
|
||||
field_started_quoted = false;
|
||||
if !on_record(std::mem::take(&mut record)) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
field.push(c);
|
||||
}
|
||||
}
|
||||
if !field.is_empty() || !record.is_empty() || field_started_quoted {
|
||||
if record.len() < MAX_COLS as usize {
|
||||
record.push(field);
|
||||
}
|
||||
on_record(record);
|
||||
}
|
||||
/// [`craft_csv::parse_records`] with a record kept to the grid's [`MAX_COLS`] fields.
|
||||
fn parse_records(text: &str, delim: char, quote: char, on_record: impl FnMut(Vec<String>) -> bool) {
|
||||
craft_csv::parse_records(text, delim, quote, MAX_COLS as usize, on_record);
|
||||
}
|
||||
|
||||
/// How many rows and bytes of a text file are read to decide each column's date order.
|
||||
@@ -587,25 +359,12 @@ pub fn write_csv_limited(sheet: &Sheet, delimiter: u8, limit: usize, mut cell_te
|
||||
out.push(delim);
|
||||
}
|
||||
let t = cell_text(CellRef::new(row, col));
|
||||
let quoted = t.contains(delim) || t.contains('"') || t.contains('\n') || t.contains('\r');
|
||||
// The exact size of the field as written (the delimiter was pushed above), so a row
|
||||
// never grows past the cap unseen and no doubled-quote copy of the text is built.
|
||||
let field_len = if quoted { t.len() + t.matches('"').count() + 2 } else { t.len() };
|
||||
if out.len().saturating_add(field_len) > limit {
|
||||
// never grows past the cap unseen.
|
||||
if out.len().saturating_add(craft_csv::field_len(&t, delim)) > limit {
|
||||
return Err(too_large(format!("more than {} MiB (stopped at row {})", limit >> 20, row + 1)));
|
||||
}
|
||||
if quoted {
|
||||
out.push('"');
|
||||
for ch in t.chars() {
|
||||
if ch == '"' {
|
||||
out.push('"');
|
||||
}
|
||||
out.push(ch);
|
||||
}
|
||||
out.push('"');
|
||||
} else {
|
||||
out.push_str(&t);
|
||||
}
|
||||
craft_csv::push_field(&mut out, &t, delim);
|
||||
}
|
||||
out.push_str("\r\n");
|
||||
if out.len() > limit {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use gridcraft_core::{CellRef, DateSystem, MAX_COLS, Value};
|
||||
use gridcraft_model::{Sheet, Workbook};
|
||||
|
||||
use crate::csv::{decode_text, parse_records, sniff_delimiter};
|
||||
use crate::{CsvOptions, Format, IoError, read_csv, read_csv_with_report, sniff, write_csv, write_csv_limited};
|
||||
use craft_csv::{decode_text, parse_records, sniff_delimiter};
|
||||
|
||||
fn v(wb: &Workbook, r: u32, c: u32) -> Value {
|
||||
wb.sheet(0).unwrap().value(CellRef::new(r, c))
|
||||
@@ -148,33 +148,6 @@ fn encodings() {
|
||||
assert_eq!(v(&wb, 0, 1), Value::Number(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_1256_table_matches_the_unicode_mapping() {
|
||||
let at = |b: u8| crate::csv::CP1256.get(usize::from(b - 0x80)).and_then(|&c| char::from_u32(c));
|
||||
let expected = [
|
||||
(0x80, '€'),
|
||||
(0x81, 'پ'),
|
||||
(0x8D, 'چ'),
|
||||
(0x98, 'ک'),
|
||||
(0x9D, '\u{200C}'),
|
||||
(0xA1, '،'),
|
||||
(0xBA, '؛'),
|
||||
(0xBF, '؟'),
|
||||
(0xC7, 'ا'),
|
||||
(0xC8, 'ب'),
|
||||
(0xDC, 'ـ'),
|
||||
(0xE1, 'ل'),
|
||||
(0xE3, 'م'),
|
||||
(0xE9, 'é'),
|
||||
(0xFD, '\u{200E}'),
|
||||
(0xFE, '\u{200F}'),
|
||||
(0xFF, 'ے'),
|
||||
];
|
||||
for (b, c) in expected {
|
||||
assert_eq!(at(b), Some(c), "0x{b:02X}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_csv_in_windows_1256_reads_as_arabic() {
|
||||
let bytes = [
|
||||
@@ -393,14 +366,14 @@ fn parser_stops_collecting_fields_at_max_cols() {
|
||||
let mut text = ",".repeat((MAX_COLS + extra) as usize - 1);
|
||||
text.push_str("end\nx,y\n");
|
||||
let mut lens = vec![];
|
||||
parse_records(&text, ',', '"', |rec| {
|
||||
parse_records(&text, ',', '"', MAX_COLS as usize, |rec| {
|
||||
lens.push(rec.len());
|
||||
true
|
||||
});
|
||||
assert_eq!(lens, vec![MAX_COLS as usize, 2]);
|
||||
// An unterminated last row is capped the same way.
|
||||
let mut lens = vec![];
|
||||
parse_records(&",".repeat(MAX_COLS as usize + extra as usize), ',', '"', |rec| {
|
||||
parse_records(&",".repeat(MAX_COLS as usize + extra as usize), ',', '"', MAX_COLS as usize, |rec| {
|
||||
lens.push(rec.len());
|
||||
true
|
||||
});
|
||||
|
||||
@@ -392,12 +392,12 @@ fn text_result_of_a_formula_kept_as_its_value() {
|
||||
|
||||
#[test]
|
||||
fn refuses_encrypted_old_and_foreign_files() {
|
||||
// FILEPASS in the globals: an encrypted workbook.
|
||||
// FILEPASS in the globals: a damaged encryption record must be rejected as such.
|
||||
let mut stream = bof(0x0005);
|
||||
stream.extend(rec(0x002F, &[1, 0, 1, 0]));
|
||||
stream.extend(eof());
|
||||
let err = read_xls(&compound("Workbook", &stream)).unwrap_err().to_string();
|
||||
assert!(err.contains("password-protected"), "{err}");
|
||||
assert!(err.contains("encryption record is damaged"), "{err}");
|
||||
// A BIFF5 (Excel 5.0/95) workbook keeps its data in a `Book` stream.
|
||||
let book = compound("Book", &stream);
|
||||
assert_eq!(sniff(&book), Format::Xls);
|
||||
@@ -484,6 +484,45 @@ fn dialog_sheets_are_skipped_not_read_as_worksheets() {
|
||||
assert!(report.warnings.iter().any(|w| w.contains("Dialog sheets")), "{:?}", report.warnings);
|
||||
}
|
||||
|
||||
/// A workbook opens on the sheet it was saved on (WINDOW1's `itabCur`), as Excel does: counted
|
||||
/// over all the file's sheets, skipped ones included. A hidden or skipped sheet, or no window
|
||||
/// record, falls back to the first visible sheet.
|
||||
#[test]
|
||||
fn opens_on_the_sheet_it_was_saved_on() {
|
||||
// BoundSheet: position, visibility (0 visible, 1 hidden), kind, name.
|
||||
let boundsheet = |pos: u32, state: u8, name: &str| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[state, 0, name.len() as u8, 0], name.as_bytes()]));
|
||||
let window1 = |tab: u16| rec(0x003D, &u16s(&[0, 0, 0x4000, 0x2000, 0x0038, tab, 0, 1, 600]));
|
||||
let dialog = cat(&[&bof(0x0010), &rec(0x0081, &[0x10, 0x00]), &eof()]);
|
||||
let worksheet = |n: f64| cat(&[&bof(0x0010), &rec(0x0081, &[0x01, 0x04]), &rec(0x0203, &cat(&[&cell(0, 0, 0), &n.to_le_bytes()])), &eof()]);
|
||||
// Dialog1 (skipped), Sheet1, Sheet2, Hidden (hidden).
|
||||
let book = |window: &[u8]| {
|
||||
let globals = |p: [u32; 4]| {
|
||||
cat(&[
|
||||
&bof(0x0005),
|
||||
window,
|
||||
&boundsheet(p[0], 0, "Dialog1"),
|
||||
&boundsheet(p[1], 0, "Sheet1"),
|
||||
&boundsheet(p[2], 0, "Sheet2"),
|
||||
&boundsheet(p[3], 1, "Hidden"),
|
||||
&eof(),
|
||||
])
|
||||
};
|
||||
let (d, s1, s2, h) = (dialog.clone(), worksheet(1.0), worksheet(2.0), worksheet(3.0));
|
||||
let base = globals([0; 4]).len() as u32;
|
||||
let p = [base, base + d.len() as u32, base + (d.len() + s1.len()) as u32, base + (d.len() + s1.len() + s2.len()) as u32];
|
||||
let stream = cat(&[&globals(p), &d, &s1, &s2, &h]);
|
||||
let (wb, _) = read_xls(&compound("Workbook", &stream)).unwrap();
|
||||
wb.active().map(|s| s.name.clone()).unwrap_or_default()
|
||||
};
|
||||
assert_eq!(book(&window1(2)), "Sheet2", "itabCur 2 is Sheet2, counting the skipped dialog sheet");
|
||||
assert_eq!(book(&window1(1)), "Sheet1");
|
||||
assert_eq!(book(&window1(3)), "Sheet1", "a hidden sheet falls back to the first visible one");
|
||||
assert_eq!(book(&window1(0)), "Sheet1", "a skipped sheet falls back to the first visible one");
|
||||
assert_eq!(book(&window1(99)), "Sheet1", "an index past the sheets falls back too");
|
||||
assert_eq!(book(&[]), "Sheet1", "no window record");
|
||||
assert_eq!(book(&rec(0x003D, &u16s(&[0, 0, 0, 0, 0]))), "Sheet1", "a window record too short for the index");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sheets_share_one_record_budget() {
|
||||
// Many BoundSheet records point into one long substream (at successive nested BOFs), so each
|
||||
@@ -509,10 +548,13 @@ fn sheets_share_one_record_budget() {
|
||||
let file = compound("Workbook", &stream);
|
||||
// About n × 2,000 records in all: over a 100,000 shared allowance, though each sheet alone fits.
|
||||
assert!(matches!(
|
||||
crate::xls::read_xls_budgeted(&file, crate::xls::Budget::with_max_records(100_000), crate::FunctionInfo::default()),
|
||||
crate::xls::read_xls_budgeted(&file, craft_xls::Limits { max_records: 100_000, ..Default::default() }, crate::FunctionInfo::default()),
|
||||
Err(IoError::TooLarge(_))
|
||||
));
|
||||
assert!(crate::xls::read_xls_budgeted(&file, crate::xls::Budget::with_max_records(10_000_000), crate::FunctionInfo::default()).is_ok());
|
||||
assert!(
|
||||
crate::xls::read_xls_budgeted(&file, craft_xls::Limits { max_records: 10_000_000, ..Default::default() }, crate::FunctionInfo::default())
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
// Two sheets at the same offset are refused outright.
|
||||
let mut dup = bof(0x0005);
|
||||
@@ -768,15 +810,17 @@ fn xls_short_window2_is_ignored() {
|
||||
fn shared_formula_text_counts_against_the_text_allowance() {
|
||||
let file = compound("Workbook", &sample(false));
|
||||
// The smallest allowance that reads every string: nothing is left for expanded formulas.
|
||||
let left = (0..1000).find(|n| crate::xls::read_xls_budgeted(&file, crate::xls::Budget::with_text_left(*n), Default::default()).is_ok()).unwrap();
|
||||
let (wb, report) = crate::xls::read_xls_budgeted(&file, crate::xls::Budget::with_text_left(left), Default::default()).unwrap();
|
||||
let left = (0..1000)
|
||||
.find(|n| crate::xls::read_xls_budgeted(&file, craft_xls::Limits { max_text: *n, ..Default::default() }, Default::default()).is_ok())
|
||||
.unwrap();
|
||||
let (wb, report) = crate::xls::read_xls_budgeted(&file, craft_xls::Limits { max_text: left, ..Default::default() }, Default::default()).unwrap();
|
||||
assert_eq!(ftext(&wb, "B6"), None);
|
||||
assert_eq!(v(&wb, 0, "B6"), Value::Number(20.0));
|
||||
assert!(report.warnings.iter().any(|w| w.contains("shared-formula text")), "{:?}", report.warnings);
|
||||
// an array anchor after the cut-off still gets its formula
|
||||
assert!(ftext(&wb, "C5").is_some());
|
||||
// With room, the followers keep their formulas.
|
||||
let (wb, _) = crate::xls::read_xls_budgeted(&file, crate::xls::Budget::default(), Default::default()).unwrap();
|
||||
let (wb, _) = crate::xls::read_xls_budgeted(&file, Default::default(), Default::default()).unwrap();
|
||||
assert!(ftext(&wb, "B6").is_some());
|
||||
}
|
||||
|
||||
@@ -798,12 +842,14 @@ fn shared_formulas_do_not_starve_a_later_sheets_text() {
|
||||
let p1 = globals([0; 2]).len() as u32;
|
||||
let file = compound("Workbook", &cat(&[&globals([p1, p1 + one.len() as u32]), &one, &two]));
|
||||
// The smallest allowance that reads every string leaves nothing for expanded formulas.
|
||||
let left = (0..1000).find(|n| crate::xls::read_xls_budgeted(&file, crate::xls::Budget::with_text_left(*n), Default::default()).is_ok()).unwrap();
|
||||
let (wb, _) = crate::xls::read_xls_budgeted(&file, crate::xls::Budget::with_text_left(left), Default::default()).unwrap();
|
||||
let left = (0..1000)
|
||||
.find(|n| crate::xls::read_xls_budgeted(&file, craft_xls::Limits { max_text: *n, ..Default::default() }, Default::default()).is_ok())
|
||||
.unwrap();
|
||||
let (wb, _) = crate::xls::read_xls_budgeted(&file, craft_xls::Limits { max_text: left, ..Default::default() }, Default::default()).unwrap();
|
||||
assert_eq!(v(&wb, 1, "A1"), Value::text("abcdefgh"));
|
||||
assert_eq!(ftext(&wb, "B6"), None);
|
||||
assert_eq!(v(&wb, 0, "B6"), Value::Number(20.0));
|
||||
let (wb, _) = crate::xls::read_xls_budgeted(&file, crate::xls::Budget::default(), Default::default()).unwrap();
|
||||
let (wb, _) = crate::xls::read_xls_budgeted(&file, Default::default(), Default::default()).unwrap();
|
||||
assert!(ftext(&wb, "B6").is_some());
|
||||
}
|
||||
|
||||
@@ -826,7 +872,7 @@ fn xls_duplicate_sheet_names_are_refused() {
|
||||
g.extend(&sheet);
|
||||
compound("Workbook", &g)
|
||||
};
|
||||
let err = crate::xls::read_xls_budgeted(&file("One", "one"), crate::xls::Budget::default(), Default::default()).unwrap_err().to_string();
|
||||
let err = crate::xls::read_xls_budgeted(&file("One", "one"), Default::default(), Default::default()).unwrap_err().to_string();
|
||||
assert!(err.contains("already taken"), "{err}");
|
||||
assert!(crate::xls::read_xls_budgeted(&file("One", "Two"), crate::xls::Budget::default(), Default::default()).is_ok());
|
||||
assert!(crate::xls::read_xls_budgeted(&file("One", "Two"), Default::default(), Default::default()).is_ok());
|
||||
}
|
||||
|
||||
+328
-896
File diff suppressed because it is too large
Load Diff
@@ -1,551 +0,0 @@
|
||||
//! BIFF8 parsed formulas ([MS-XLS] 2.2.2, 2.5.198): reverse-Polish `Ptg` tokens back to an
|
||||
//! expression tree. Anything outside the common token set is reported as unsupported, and the
|
||||
//! caller keeps the cell's cached value instead.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{CellError, CellRef};
|
||||
use gridcraft_formula::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, UnOp};
|
||||
|
||||
use super::ftab::FTAB;
|
||||
|
||||
/// Formula token bytes decoded at most (Excel's limit is 1,800 per formula).
|
||||
const MAX_RGCE: usize = 16 * 1024;
|
||||
/// Values in one array constant at most.
|
||||
const MAX_ARRAY: usize = 65_536;
|
||||
|
||||
/// Why a formula could not be turned back into text.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(super) struct Unsupported(pub &'static str);
|
||||
|
||||
/// Where the formula lives: refs in cell formulas store coordinates; shared formulas store
|
||||
/// row/column offsets for relative parts; names store offsets for 3-D refs too.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub(super) enum Mode {
|
||||
Cell,
|
||||
Shared,
|
||||
Name,
|
||||
}
|
||||
|
||||
/// A supporting link ([MS-XLS] 2.4.271): only links to this workbook (and add-in function
|
||||
/// names) are resolved; external workbooks are never followed.
|
||||
#[derive(Default)]
|
||||
pub(super) struct SupBook {
|
||||
pub internal: bool,
|
||||
pub addin: bool,
|
||||
pub names: Vec<String>,
|
||||
}
|
||||
|
||||
/// XTI ([MS-XLS] 2.5.344): a supporting link and a range of its sheets.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct Xti {
|
||||
pub book: u16,
|
||||
pub first: i16,
|
||||
pub last: i16,
|
||||
}
|
||||
|
||||
/// Workbook-level tables formulas refer to.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Links {
|
||||
/// Every sheet's name in BoundSheet8 order (including skipped chart/macro sheets).
|
||||
pub sheets: Vec<String>,
|
||||
/// Defined names (Lbl records), in order; `None` for names that can't be referenced.
|
||||
pub names: Vec<String>,
|
||||
/// Per defined name: whether the import skips it (its formula can't be decoded).
|
||||
pub skipped: Vec<bool>,
|
||||
pub books: Vec<SupBook>,
|
||||
pub xti: Vec<Xti>,
|
||||
}
|
||||
|
||||
pub(super) fn function(iftab: u16) -> Option<(&'static str, i8)> {
|
||||
FTAB.binary_search_by_key(&iftab, |(i, _, _)| *i).ok().and_then(|i| FTAB.get(i)).map(|(_, n, a)| (*n, *a))
|
||||
}
|
||||
|
||||
fn error_code(code: u8) -> Option<CellError> {
|
||||
Some(match code {
|
||||
0x00 => CellError::Null,
|
||||
0x07 => CellError::Div0,
|
||||
0x0F => CellError::Value,
|
||||
0x17 => CellError::Ref,
|
||||
0x1D => CellError::Name,
|
||||
0x24 => CellError::Num,
|
||||
0x2A => CellError::NA,
|
||||
0x2B => CellError::GettingData,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn cell_error(code: u8) -> CellError {
|
||||
error_code(code).unwrap_or(CellError::NA)
|
||||
}
|
||||
|
||||
struct Bytes<'a> {
|
||||
b: &'a [u8],
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
const TRUNC: Unsupported = Unsupported("truncated formula");
|
||||
|
||||
impl Bytes<'_> {
|
||||
fn take(&mut self, n: usize) -> Result<&[u8], Unsupported> {
|
||||
let s = self.b.get(self.pos..self.pos.checked_add(n).ok_or(TRUNC)?).ok_or(TRUNC)?;
|
||||
self.pos += n;
|
||||
Ok(s)
|
||||
}
|
||||
fn u8(&mut self) -> Result<u8, Unsupported> {
|
||||
self.take(1)?.first().copied().ok_or(TRUNC)
|
||||
}
|
||||
fn u16(&mut self) -> Result<u16, Unsupported> {
|
||||
let s = self.take(2)?;
|
||||
Ok(u16::from_le_bytes([*s.first().ok_or(TRUNC)?, *s.get(1).ok_or(TRUNC)?]))
|
||||
}
|
||||
fn u32(&mut self) -> Result<u32, Unsupported> {
|
||||
Ok(u32::from(self.u16()?) | (u32::from(self.u16()?) << 16))
|
||||
}
|
||||
fn f64(&mut self) -> Result<f64, Unsupported> {
|
||||
let a: [u8; 8] = self.take(8)?.try_into().map_err(|_| TRUNC)?;
|
||||
Ok(f64::from_le_bytes(a))
|
||||
}
|
||||
fn chars(&mut self, cch: usize, high: bool) -> Result<String, Unsupported> {
|
||||
let units: Vec<u16> = if high {
|
||||
self.take(cch.checked_mul(2).ok_or(TRUNC)?)?.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect()
|
||||
} else {
|
||||
self.take(cch)?.iter().map(|&b| u16::from(b)).collect()
|
||||
};
|
||||
Ok(String::from_utf16_lossy(&units))
|
||||
}
|
||||
}
|
||||
|
||||
/// A column field (ColRelU / ColRelNegU): the 14-bit column and the two relative flags.
|
||||
fn col_field(v: u16) -> (u16, bool, bool) {
|
||||
(v & 0x3FFF, v & 0x4000 != 0, v & 0x8000 != 0)
|
||||
}
|
||||
|
||||
/// One end of a reference. In `relative` (RgceLocRel) form the relative parts are offsets
|
||||
/// from `base`, wrapped into the BIFF8 grid (65,536 × 256).
|
||||
fn anchor(row: u16, col: u16, base: CellRef, relative: bool) -> Anchor {
|
||||
let (c, col_rel, row_rel) = col_field(col);
|
||||
let (r, c) = if relative {
|
||||
let r = if row_rel { (u32::from(row).wrapping_add(base.row)) & 0xFFFF } else { u32::from(row) };
|
||||
// A 14-bit signed offset; adding modulo 256 gives the same column either way.
|
||||
let c = if col_rel { (u32::from(c).wrapping_add(base.col)) & 0xFF } else { u32::from(c) & 0xFF };
|
||||
(r, c)
|
||||
} else {
|
||||
(u32::from(row), u32::from(c) & 0xFF)
|
||||
};
|
||||
Anchor { row: r, col: c, row_abs: !row_rel, col_abs: !col_rel }
|
||||
}
|
||||
|
||||
/// An area; a range spanning every BIFF8 row (or column) becomes a whole-column (row) ref.
|
||||
fn area(r0: u16, r1: u16, c0: u16, c1: u16, base: CellRef, relative: bool) -> RefKind {
|
||||
let a = anchor(r0, c0, base, relative);
|
||||
let b = anchor(r1, c1, base, relative);
|
||||
if a.row == 0 && b.row == 0xFFFF {
|
||||
return RefKind::Cols(a.col, a.col_abs, b.col, b.col_abs);
|
||||
}
|
||||
if a.col == 0 && b.col == 0xFF {
|
||||
return RefKind::Rows(a.row, a.row_abs, b.row, b.row_abs);
|
||||
}
|
||||
RefKind::Range(a, b)
|
||||
}
|
||||
|
||||
impl Links {
|
||||
fn sheet_sel(&self, ixti: u16) -> Result<Option<SheetSel>, Unsupported> {
|
||||
let x = self.xti.get(usize::from(ixti)).ok_or(Unsupported("reference to a missing sheet link"))?;
|
||||
let book = self.books.get(usize::from(x.book)).ok_or(Unsupported("reference to a missing workbook link"))?;
|
||||
if !book.internal {
|
||||
return Err(Unsupported("references to other workbooks (external links)"));
|
||||
}
|
||||
let name = |i: i16| usize::try_from(i).ok().and_then(|i| self.sheets.get(i)).cloned();
|
||||
Ok(match (name(x.first), name(x.last)) {
|
||||
(Some(a), Some(_)) if x.first == x.last => Some(SheetSel::Named(a)),
|
||||
(Some(a), Some(b)) => Some(SheetSel::Span(a, b)),
|
||||
// A deleted sheet: the reference is #REF!.
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn extern_name(&self, ixti: u16, index: u32) -> Result<String, Unsupported> {
|
||||
let x = self.xti.get(usize::from(ixti)).ok_or(Unsupported("reference to a missing name link"))?;
|
||||
let book = self.books.get(usize::from(x.book)).ok_or(Unsupported("reference to a missing workbook link"))?;
|
||||
if !book.internal && !book.addin {
|
||||
return Err(Unsupported("names in other workbooks (external links)"));
|
||||
}
|
||||
let i = usize::try_from(index).ok().and_then(|i| i.checked_sub(1)).ok_or(Unsupported("invalid name index"))?;
|
||||
book.names.get(i).cloned().ok_or(Unsupported("reference to a missing name"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Strips the prefixes newer functions carry in files (`_xlfn.IFERROR`).
|
||||
fn function_name(name: &str) -> String {
|
||||
let n = name.strip_prefix("_xlfn.").or_else(|| name.strip_prefix("_xlws.")).unwrap_or(name);
|
||||
n.to_ascii_uppercase()
|
||||
}
|
||||
|
||||
/// Expression nesting at most, as in the formula parser: deeper trees would overflow the stack
|
||||
/// when printed, evaluated or dropped (a hostile file can chain thousands of PtgParen tokens).
|
||||
const MAX_DEPTH: usize = 256;
|
||||
|
||||
/// Operand stack entries: an expression and its nesting depth.
|
||||
type Stack = Vec<(Expr, usize)>;
|
||||
|
||||
/// Pops an operand, recording its depth in `kids` (the deepest operand this token consumed).
|
||||
fn pop(stack: &mut Stack, kids: &mut usize) -> Result<Expr, Unsupported> {
|
||||
let (e, d) = stack.pop().ok_or(Unsupported("malformed formula (operand missing)"))?;
|
||||
*kids = (*kids).max(d);
|
||||
Ok(e)
|
||||
}
|
||||
|
||||
fn pop_n(stack: &mut Stack, n: usize, kids: &mut usize) -> Result<Vec<Expr>, Unsupported> {
|
||||
let at = stack.len().checked_sub(n).ok_or(Unsupported("malformed formula (argument missing)"))?;
|
||||
let popped = stack.split_off(at);
|
||||
*kids = popped.iter().map(|(_, d)| *d).fold(*kids, usize::max);
|
||||
Ok(popped.into_iter().map(|(e, _)| e).collect())
|
||||
}
|
||||
|
||||
/// Pushes the result of a token one level above the operands it consumed.
|
||||
fn push(stack: &mut Stack, kids: usize, e: Expr) -> Result<(), Unsupported> {
|
||||
let depth = kids.saturating_add(1);
|
||||
if depth > MAX_DEPTH {
|
||||
return Err(Unsupported("formula nested too deeply"));
|
||||
}
|
||||
stack.push((e, depth));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decodes `rgce` (tokens) with `rgcb` (the extra data for array constants) evaluated at
|
||||
/// `base`. Returns the expression or why it can't be represented.
|
||||
pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: &Links) -> Result<Expr, Unsupported> {
|
||||
if rgce.len() > MAX_RGCE {
|
||||
return Err(Unsupported("formula too long"));
|
||||
}
|
||||
let mut t = Bytes { b: rgce, pos: 0 };
|
||||
let mut extra = Bytes { b: rgcb, pos: 0 };
|
||||
let mut stack: Stack = Vec::new();
|
||||
while t.pos < rgce.len() {
|
||||
let ptg = t.u8()?;
|
||||
let mut kids = 0usize;
|
||||
// Operand classes (reference / value / array) share one token number.
|
||||
let id = if ptg & 0x60 != 0 { 0x20 | (ptg & 0x1F) } else { ptg };
|
||||
match id {
|
||||
0x03..=0x11 => {
|
||||
let op = match id {
|
||||
0x03 => BinOp::Add,
|
||||
0x04 => BinOp::Sub,
|
||||
0x05 => BinOp::Mul,
|
||||
0x06 => BinOp::Div,
|
||||
0x07 => BinOp::Pow,
|
||||
0x08 => BinOp::Concat,
|
||||
0x09 => BinOp::Lt,
|
||||
0x0A => BinOp::Le,
|
||||
0x0B => BinOp::Eq,
|
||||
0x0C => BinOp::Ge,
|
||||
0x0D => BinOp::Gt,
|
||||
0x0E => BinOp::Ne,
|
||||
0x0F => BinOp::Intersect,
|
||||
0x10 => BinOp::Union,
|
||||
_ => BinOp::Range,
|
||||
};
|
||||
let b = pop(&mut stack, &mut kids)?;
|
||||
let a = pop(&mut stack, &mut kids)?;
|
||||
push(&mut stack, kids, Expr::Binary(op, Box::new(a), Box::new(b)))?;
|
||||
}
|
||||
0x12..=0x14 => {
|
||||
let op = match id {
|
||||
0x12 => UnOp::Plus,
|
||||
0x13 => UnOp::Neg,
|
||||
_ => UnOp::Percent,
|
||||
};
|
||||
let x = pop(&mut stack, &mut kids)?;
|
||||
push(&mut stack, kids, Expr::Unary(op, Box::new(x)))?;
|
||||
}
|
||||
0x15 => {
|
||||
let x = pop(&mut stack, &mut kids)?;
|
||||
push(&mut stack, kids, Expr::Paren(Box::new(x)))?;
|
||||
}
|
||||
0x16 => push(&mut stack, kids, Expr::Missing)?,
|
||||
0x17 => {
|
||||
let cch = usize::from(t.u8()?);
|
||||
let high = t.u8()? & 1 != 0;
|
||||
push(&mut stack, kids, Expr::Text(Arc::from(t.chars(cch, high)?)))?;
|
||||
}
|
||||
0x19 => {
|
||||
let kind = t.u8()?;
|
||||
if kind & 0x04 != 0 {
|
||||
// PtgAttrChoose: jump table of cOffset + 1 entries.
|
||||
let n = usize::from(t.u16()?) + 1;
|
||||
t.take(n * 2)?;
|
||||
} else {
|
||||
t.take(2)?;
|
||||
}
|
||||
if kind & 0x10 != 0 {
|
||||
// PtgAttrSum: SUM of the single operand.
|
||||
let x = pop(&mut stack, &mut kids)?;
|
||||
push(&mut stack, kids, Expr::Call("SUM".into(), vec![x]))?;
|
||||
}
|
||||
// Semi (volatile), If, Goto, Space, Baxcel: no effect on the expression.
|
||||
}
|
||||
0x1C => {
|
||||
let e = t.u8()?;
|
||||
push(&mut stack, kids, Expr::Error(error_code(e).ok_or(Unsupported("unknown error constant"))?))?;
|
||||
}
|
||||
0x1D => push(&mut stack, kids, Expr::Bool(t.u8()? != 0))?,
|
||||
0x1E => push(&mut stack, kids, Expr::Number(f64::from(t.u16()?)))?,
|
||||
0x1F => {
|
||||
let n = t.f64()?;
|
||||
if !n.is_finite() {
|
||||
return Err(Unsupported("invalid number constant"));
|
||||
}
|
||||
push(&mut stack, kids, Expr::Number(n))?;
|
||||
}
|
||||
0x20 => {
|
||||
t.take(7)?;
|
||||
push(&mut stack, kids, array_constant(&mut extra)?)?;
|
||||
}
|
||||
0x21 => {
|
||||
let iftab = t.u16()?;
|
||||
let (name, args) = function(iftab).ok_or(Unsupported("unknown function"))?;
|
||||
let n = usize::try_from(args).map_err(|_| Unsupported("unknown function argument count"))?;
|
||||
let args = pop_n(&mut stack, n, &mut kids)?;
|
||||
push(&mut stack, kids, Expr::Call(name.to_string(), args))?;
|
||||
}
|
||||
0x22 => {
|
||||
let n = usize::from(t.u8()?);
|
||||
let tab = t.u16()?;
|
||||
if tab & 0x8000 != 0 {
|
||||
return Err(Unsupported("macro sheet commands"));
|
||||
}
|
||||
let mut args = pop_n(&mut stack, n, &mut kids)?;
|
||||
if tab & 0x7FFF == 0x00FF {
|
||||
// A function called by name (add-in or newer function): the name comes first.
|
||||
if args.is_empty() {
|
||||
return Err(Unsupported("malformed function call"));
|
||||
}
|
||||
let name = match args.remove(0) {
|
||||
Expr::Name(n) => function_name(&n),
|
||||
_ => return Err(Unsupported("function call through an expression")),
|
||||
};
|
||||
push(&mut stack, kids, Expr::Call(name, args))?;
|
||||
} else {
|
||||
let (name, _) = function(tab & 0x7FFF).ok_or(Unsupported("unknown function"))?;
|
||||
push(&mut stack, kids, Expr::Call(name.to_string(), args))?;
|
||||
}
|
||||
}
|
||||
0x23 => {
|
||||
let i = usize::try_from(t.u32()?).ok().and_then(|i| i.checked_sub(1));
|
||||
let name = i.and_then(|i| links.names.get(i)).filter(|n| !n.is_empty()).ok_or(Unsupported("reference to a missing defined name"))?;
|
||||
// A cell using a name the import drops would calculate to #NAME?: keep its
|
||||
// saved result instead, as for references to other workbooks.
|
||||
if mode != Mode::Name && i.and_then(|i| links.skipped.get(i)) == Some(&true) {
|
||||
return Err(Unsupported("defined names GridCraft skipped"));
|
||||
}
|
||||
push(&mut stack, kids, Expr::Name(name.clone()))?;
|
||||
}
|
||||
0x24 | 0x2C => {
|
||||
let (r, c) = (t.u16()?, t.u16()?);
|
||||
let a = anchor(r, c, base, id == 0x2C);
|
||||
push(&mut stack, kids, Expr::Ref(Reference { sheet: SheetSel::Current, kind: RefKind::Cell(a) }))?;
|
||||
}
|
||||
0x25 | 0x2D => {
|
||||
let (r0, r1, c0, c1) = (t.u16()?, t.u16()?, t.u16()?, t.u16()?);
|
||||
let kind = area(r0, r1, c0, c1, base, id == 0x2D);
|
||||
push(&mut stack, kids, Expr::Ref(Reference { sheet: SheetSel::Current, kind }))?;
|
||||
}
|
||||
0x26 => {
|
||||
// PtgMemArea: a cache of the sub-expression that follows; skip its extra data.
|
||||
t.take(6)?;
|
||||
let n = usize::from(extra.u16()?);
|
||||
extra.take(n.checked_mul(8).ok_or(TRUNC)?)?;
|
||||
}
|
||||
0x27 | 0x28 => {
|
||||
t.take(6)?;
|
||||
}
|
||||
0x29 => {
|
||||
t.take(2)?;
|
||||
}
|
||||
0x2A => {
|
||||
t.take(4)?;
|
||||
push(&mut stack, kids, Expr::Error(CellError::Ref))?;
|
||||
}
|
||||
0x2B => {
|
||||
t.take(8)?;
|
||||
push(&mut stack, kids, Expr::Error(CellError::Ref))?;
|
||||
}
|
||||
0x39 => {
|
||||
let ixti = t.u16()?;
|
||||
let i = t.u32()?;
|
||||
push(&mut stack, kids, Expr::Name(links.extern_name(ixti, i)?))?;
|
||||
}
|
||||
0x3A => {
|
||||
let ixti = t.u16()?;
|
||||
let (r, c) = (t.u16()?, t.u16()?);
|
||||
let a = anchor(r, c, base, mode == Mode::Name);
|
||||
push(
|
||||
&mut stack,
|
||||
kids,
|
||||
match links.sheet_sel(ixti)? {
|
||||
Some(sheet) => Expr::Ref(Reference { sheet, kind: RefKind::Cell(a) }),
|
||||
None => Expr::Error(CellError::Ref),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
0x3B => {
|
||||
let ixti = t.u16()?;
|
||||
let (r0, r1, c0, c1) = (t.u16()?, t.u16()?, t.u16()?, t.u16()?);
|
||||
let kind = area(r0, r1, c0, c1, base, mode == Mode::Name);
|
||||
push(
|
||||
&mut stack,
|
||||
kids,
|
||||
match links.sheet_sel(ixti)? {
|
||||
Some(sheet) => Expr::Ref(Reference { sheet, kind }),
|
||||
None => Expr::Error(CellError::Ref),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
0x3C => {
|
||||
t.take(6)?;
|
||||
push(&mut stack, kids, Expr::Error(CellError::Ref))?;
|
||||
}
|
||||
0x3D => {
|
||||
t.take(10)?;
|
||||
push(&mut stack, kids, Expr::Error(CellError::Ref))?;
|
||||
}
|
||||
0x01 => return Err(Unsupported("nested shared or array formula reference")),
|
||||
0x02 => return Err(Unsupported("what-if data tables")),
|
||||
0x18 => return Err(Unsupported("natural-language formulas")),
|
||||
_ => return Err(Unsupported("unknown formula token")),
|
||||
}
|
||||
if stack.len() > MAX_RGCE {
|
||||
return Err(Unsupported("formula too complex"));
|
||||
}
|
||||
}
|
||||
let (e, _) = stack.pop().ok_or(Unsupported("malformed formula (operand missing)"))?;
|
||||
if !stack.is_empty() {
|
||||
return Err(Unsupported("malformed formula (unused operands)"));
|
||||
}
|
||||
Ok(e)
|
||||
}
|
||||
|
||||
/// PtgExtraArray ([MS-XLS] 2.5.198.59): an array constant's values in row-major order.
|
||||
fn array_constant(b: &mut Bytes<'_>) -> Result<Expr, Unsupported> {
|
||||
let cols = usize::from(b.u8()?) + 1;
|
||||
let rows = usize::from(b.u16()?) + 1;
|
||||
if cols.saturating_mul(rows) > MAX_ARRAY {
|
||||
return Err(Unsupported("array constant too large"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(rows);
|
||||
for _ in 0..rows {
|
||||
let mut row = Vec::with_capacity(cols);
|
||||
for _ in 0..cols {
|
||||
let kind = b.u8()?;
|
||||
row.push(match kind {
|
||||
0x00 => {
|
||||
b.take(8)?;
|
||||
Expr::Missing
|
||||
}
|
||||
0x01 => {
|
||||
let n = b.f64()?;
|
||||
if !n.is_finite() {
|
||||
return Err(Unsupported("invalid number constant"));
|
||||
}
|
||||
Expr::Number(n)
|
||||
}
|
||||
0x02 => {
|
||||
let cch = usize::from(b.u16()?);
|
||||
let high = b.u8()? & 1 != 0;
|
||||
Expr::Text(Arc::from(b.chars(cch, high)?))
|
||||
}
|
||||
0x04 => {
|
||||
let v = b.u8()?;
|
||||
b.take(7)?;
|
||||
Expr::Bool(v != 0)
|
||||
}
|
||||
0x10 => {
|
||||
let e = b.u8()?;
|
||||
b.take(7)?;
|
||||
Expr::Error(error_code(e).ok_or(Unsupported("unknown error constant"))?)
|
||||
}
|
||||
_ => return Err(Unsupported("unknown array constant value")),
|
||||
});
|
||||
}
|
||||
out.push(row);
|
||||
}
|
||||
Ok(Expr::Array(out))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn links() -> Links {
|
||||
Links {
|
||||
sheets: vec!["Sheet1".into(), "My Data".into()],
|
||||
names: vec!["Rate".into()],
|
||||
skipped: vec![false],
|
||||
books: vec![
|
||||
SupBook { internal: true, addin: false, names: vec![] },
|
||||
SupBook { internal: false, addin: true, names: vec!["_xlfn.IFERROR".into()] },
|
||||
SupBook::default(),
|
||||
],
|
||||
xti: vec![
|
||||
Xti { book: 0, first: 0, last: 1 },
|
||||
Xti { book: 1, first: -2, last: -2 },
|
||||
Xti { book: 2, first: 0, last: 0 },
|
||||
Xti { book: 0, first: -1, last: -1 },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn text(rgce: &[u8], rgcb: &[u8]) -> Result<String, Unsupported> {
|
||||
decode(rgce, rgcb, CellRef::new(0, 0), Mode::Cell, &links()).map(|e| gridcraft_formula::print(&e))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_common_tokens() {
|
||||
// {1,"a";TRUE,#N/A} via PtgArray + PtgExtraArray.
|
||||
let extra =
|
||||
[&[1u8, 1, 0][..], &[1], &1f64.to_le_bytes(), &[2, 1, 0, 0, b'a'], &[4, 1, 0, 0, 0, 0, 0, 0, 0], &[0x10, 0x2A, 0, 0, 0, 0, 0, 0, 0]]
|
||||
.concat();
|
||||
assert_eq!(text(&[0x60, 0, 0, 0, 0, 0, 0, 0], &extra).unwrap(), "{1,\"a\";TRUE,#N/A}");
|
||||
// SUM via PtgAttrSum over a 3-D span, whole column.
|
||||
let area3d = [&[0x3Bu8][..], &0u16.to_le_bytes(), &[0, 0, 0xFF, 0xFF, 1, 0, 1, 0]].concat();
|
||||
assert_eq!(text(&[area3d.as_slice(), &[0x19, 0x10, 0, 0]].concat(), &[]).unwrap(), "SUM('Sheet1:My Data'!$B:$B)");
|
||||
// IFERROR (a newer function) called by name through PtgNameX, with a missing argument.
|
||||
let namex = [&[0x39u8][..], &1u16.to_le_bytes(), &1u32.to_le_bytes()].concat();
|
||||
let call = [namex.as_slice(), &[0x1E, 5, 0, 0x16, 0x22, 3, 0xFF, 0]].concat();
|
||||
assert_eq!(text(&call, &[]).unwrap(), "IFERROR(5,)");
|
||||
// A deleted sheet becomes #REF!; the CHOOSE jump table is skipped.
|
||||
let ref_deleted = [&[0x3Au8][..], &3u16.to_le_bytes(), &[0, 0, 0, 0]].concat();
|
||||
let choose = [&[0x1Eu8, 1, 0][..], &[0x19, 0x04, 1, 0, 0, 0, 0, 0], ref_deleted.as_slice(), &[0x1E, 2, 0, 0x22, 3, 100, 0]].concat();
|
||||
assert_eq!(text(&choose, &[]).unwrap(), "CHOOSE(1,#REF!,2)");
|
||||
// External links, data tables and unbalanced stacks are refused, never guessed.
|
||||
let external = [&[0x3Au8][..], &2u16.to_le_bytes(), &[0, 0, 0, 0]].concat();
|
||||
assert!(text(&external, &[]).is_err());
|
||||
assert!(text(&[0x02, 0, 0, 0, 0], &[]).is_err());
|
||||
assert!(text(&[0x1E, 1, 0, 0x1E, 2, 0], &[]).is_err());
|
||||
assert!(text(&[0x03], &[]).is_err());
|
||||
assert!(text(&[0x21, 0xFF, 0x7F], &[]).is_err());
|
||||
assert!(text(&[0x60, 0, 0, 0, 0, 0, 0, 0], &[0xFF, 0xFF, 0xFF]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_nesting_is_refused_not_a_stack_overflow() {
|
||||
// 1 followed by thousands of PtgParen / PtgUminus tokens: a hostile file used to build a
|
||||
// 16K-deep tree that overflowed the stack when printed or dropped.
|
||||
for op in [0x15u8, 0x13] {
|
||||
let mut rgce = vec![0x1E, 1, 0];
|
||||
rgce.resize(MAX_RGCE, op);
|
||||
assert_eq!(text(&rgce, &[]), Err(Unsupported("formula nested too deeply")));
|
||||
}
|
||||
// Binary chains nest too: 1+1+1+... left-associated.
|
||||
let mut chain = vec![0x1E, 1, 0];
|
||||
while chain.len() + 4 <= MAX_RGCE {
|
||||
chain.extend([0x1E, 1, 0, 0x03]);
|
||||
}
|
||||
assert!(text(&chain, &[]).is_err());
|
||||
// Nesting up to the limit still decodes.
|
||||
let mut ok = vec![0x1E, 1, 0];
|
||||
ok.resize(3 + MAX_DEPTH - 1, 0x15);
|
||||
assert!(text(&ok, &[]).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -1,376 +0,0 @@
|
||||
// Generated from the Ftab table of [MS-XLS] section 2.5.198.17: (iftab, name, fixed argument
|
||||
// count or -1 when the function takes a variable number of arguments).
|
||||
pub(super) const FTAB: &[(u16, &str, i8)] = &[
|
||||
(0, "COUNT", -1),
|
||||
(1, "IF", -1),
|
||||
(2, "ISNA", 1),
|
||||
(3, "ISERROR", 1),
|
||||
(4, "SUM", -1),
|
||||
(5, "AVERAGE", -1),
|
||||
(6, "MIN", -1),
|
||||
(7, "MAX", -1),
|
||||
(8, "ROW", -1),
|
||||
(9, "COLUMN", -1),
|
||||
(10, "NA", 0),
|
||||
(11, "NPV", -1),
|
||||
(12, "STDEV", -1),
|
||||
(13, "DOLLAR", -1),
|
||||
(14, "FIXED", -1),
|
||||
(15, "SIN", 1),
|
||||
(16, "COS", 1),
|
||||
(17, "TAN", 1),
|
||||
(18, "ATAN", 1),
|
||||
(19, "PI", 0),
|
||||
(20, "SQRT", 1),
|
||||
(21, "EXP", 1),
|
||||
(22, "LN", 1),
|
||||
(23, "LOG10", 1),
|
||||
(24, "ABS", 1),
|
||||
(25, "INT", 1),
|
||||
(26, "SIGN", 1),
|
||||
(27, "ROUND", 2),
|
||||
(28, "LOOKUP", -1),
|
||||
(29, "INDEX", -1),
|
||||
(30, "REPT", 2),
|
||||
(31, "MID", 3),
|
||||
(32, "LEN", 1),
|
||||
(33, "VALUE", 1),
|
||||
(34, "TRUE", 0),
|
||||
(35, "FALSE", 0),
|
||||
(36, "AND", -1),
|
||||
(37, "OR", -1),
|
||||
(38, "NOT", 1),
|
||||
(39, "MOD", 2),
|
||||
(40, "DCOUNT", 3),
|
||||
(41, "DSUM", 3),
|
||||
(42, "DAVERAGE", 3),
|
||||
(43, "DMIN", 3),
|
||||
(44, "DMAX", 3),
|
||||
(45, "DSTDEV", 3),
|
||||
(46, "VAR", -1),
|
||||
(47, "DVAR", 3),
|
||||
(48, "TEXT", 2),
|
||||
(49, "LINEST", -1),
|
||||
(50, "TREND", -1),
|
||||
(51, "LOGEST", -1),
|
||||
(52, "GROWTH", -1),
|
||||
(53, "GOTO", 1),
|
||||
(54, "HALT", -1),
|
||||
(55, "RETURN", -1),
|
||||
(56, "PV", -1),
|
||||
(57, "FV", -1),
|
||||
(58, "NPER", -1),
|
||||
(59, "PMT", -1),
|
||||
(60, "RATE", -1),
|
||||
(61, "MIRR", 3),
|
||||
(62, "IRR", -1),
|
||||
(63, "RAND", 0),
|
||||
(64, "MATCH", -1),
|
||||
(65, "DATE", 3),
|
||||
(66, "TIME", 3),
|
||||
(67, "DAY", 1),
|
||||
(68, "MONTH", 1),
|
||||
(69, "YEAR", 1),
|
||||
(70, "WEEKDAY", -1),
|
||||
(71, "HOUR", 1),
|
||||
(72, "MINUTE", 1),
|
||||
(73, "SECOND", 1),
|
||||
(74, "NOW", 0),
|
||||
(75, "AREAS", 1),
|
||||
(76, "ROWS", 1),
|
||||
(77, "COLUMNS", 1),
|
||||
(78, "OFFSET", -1),
|
||||
(79, "ABSREF", 2),
|
||||
(80, "RELREF", 2),
|
||||
(81, "ARGUMENT", -1),
|
||||
(82, "SEARCH", -1),
|
||||
(83, "TRANSPOSE", 1),
|
||||
(84, "ERROR", -1),
|
||||
(85, "STEP", 0),
|
||||
(86, "TYPE", 1),
|
||||
(87, "ECHO", -1),
|
||||
(88, "SET.NAME", -1),
|
||||
(89, "CALLER", 0),
|
||||
(90, "DEREF", 1),
|
||||
(91, "WINDOWS", -1),
|
||||
(92, "SERIES", -1),
|
||||
(93, "DOCUMENTS", -1),
|
||||
(94, "ACTIVE.CELL", 0),
|
||||
(95, "SELECTION", 0),
|
||||
(96, "RESULT", -1),
|
||||
(97, "ATAN2", 2),
|
||||
(98, "ASIN", 1),
|
||||
(99, "ACOS", 1),
|
||||
(100, "CHOOSE", -1),
|
||||
(101, "HLOOKUP", -1),
|
||||
(102, "VLOOKUP", -1),
|
||||
(103, "LINKS", -1),
|
||||
(104, "INPUT", -1),
|
||||
(105, "ISREF", 1),
|
||||
(106, "GET.FORMULA", 1),
|
||||
(107, "GET.NAME", -1),
|
||||
(108, "SET.VALUE", 2),
|
||||
(109, "LOG", -1),
|
||||
(110, "EXEC", -1),
|
||||
(111, "CHAR", 1),
|
||||
(112, "LOWER", 1),
|
||||
(113, "UPPER", 1),
|
||||
(114, "PROPER", 1),
|
||||
(115, "LEFT", -1),
|
||||
(116, "RIGHT", -1),
|
||||
(117, "EXACT", 2),
|
||||
(118, "TRIM", 1),
|
||||
(119, "REPLACE", 4),
|
||||
(120, "SUBSTITUTE", -1),
|
||||
(121, "CODE", 1),
|
||||
(122, "NAMES", -1),
|
||||
(123, "DIRECTORY", -1),
|
||||
(124, "FIND", -1),
|
||||
(125, "CELL", -1),
|
||||
(126, "ISERR", 1),
|
||||
(127, "ISTEXT", 1),
|
||||
(128, "ISNUMBER", 1),
|
||||
(129, "ISBLANK", 1),
|
||||
(130, "T", 1),
|
||||
(131, "N", 1),
|
||||
(132, "FOPEN", -1),
|
||||
(133, "FCLOSE", 1),
|
||||
(134, "FSIZE", 1),
|
||||
(135, "FREADLN", 1),
|
||||
(136, "FREAD", 2),
|
||||
(137, "FWRITELN", 2),
|
||||
(138, "FWRITE", 2),
|
||||
(139, "FPOS", -1),
|
||||
(140, "DATEVALUE", 1),
|
||||
(141, "TIMEVALUE", 1),
|
||||
(142, "SLN", 3),
|
||||
(143, "SYD", 4),
|
||||
(144, "DDB", -1),
|
||||
(145, "GET.DEF", -1),
|
||||
(146, "REFTEXT", -1),
|
||||
(147, "TEXTREF", -1),
|
||||
(148, "INDIRECT", -1),
|
||||
(149, "REGISTER", -1),
|
||||
(150, "CALL", -1),
|
||||
(151, "ADD.BAR", -1),
|
||||
(152, "ADD.MENU", -1),
|
||||
(153, "ADD.COMMAND", -1),
|
||||
(154, "ENABLE.COMMAND", -1),
|
||||
(155, "CHECK.COMMAND", -1),
|
||||
(156, "RENAME.COMMAND", -1),
|
||||
(157, "SHOW.BAR", -1),
|
||||
(158, "DELETE.MENU", -1),
|
||||
(159, "DELETE.COMMAND", -1),
|
||||
(160, "GET.CHART.ITEM", -1),
|
||||
(161, "DIALOG.BOX", 1),
|
||||
(162, "CLEAN", 1),
|
||||
(163, "MDETERM", 1),
|
||||
(164, "MINVERSE", 1),
|
||||
(165, "MMULT", 2),
|
||||
(166, "FILES", -1),
|
||||
(167, "IPMT", -1),
|
||||
(168, "PPMT", -1),
|
||||
(169, "COUNTA", -1),
|
||||
(170, "CANCEL.KEY", -1),
|
||||
(171, "FOR", -1),
|
||||
(172, "WHILE", 1),
|
||||
(173, "BREAK", 0),
|
||||
(174, "NEXT", 0),
|
||||
(175, "INITIATE", 2),
|
||||
(176, "REQUEST", 2),
|
||||
(177, "POKE", 3),
|
||||
(178, "EXECUTE", 2),
|
||||
(179, "TERMINATE", 1),
|
||||
(180, "RESTART", -1),
|
||||
(181, "HELP", -1),
|
||||
(182, "GET.BAR", -1),
|
||||
(183, "PRODUCT", -1),
|
||||
(184, "FACT", 1),
|
||||
(185, "GET.CELL", -1),
|
||||
(186, "GET.WORKSPACE", 1),
|
||||
(187, "GET.WINDOW", -1),
|
||||
(188, "GET.DOCUMENT", -1),
|
||||
(189, "DPRODUCT", 3),
|
||||
(190, "ISNONTEXT", 1),
|
||||
(191, "GET.NOTE", -1),
|
||||
(192, "NOTE", -1),
|
||||
(193, "STDEVP", -1),
|
||||
(194, "VARP", -1),
|
||||
(195, "DSTDEVP", 3),
|
||||
(196, "DVARP", 3),
|
||||
(197, "TRUNC", -1),
|
||||
(198, "ISLOGICAL", 1),
|
||||
(199, "DCOUNTA", 3),
|
||||
(200, "DELETE.BAR", 1),
|
||||
(201, "UNREGISTER", 1),
|
||||
(204, "USDOLLAR", -1),
|
||||
(205, "FINDB", -1),
|
||||
(206, "SEARCHB", -1),
|
||||
(207, "REPLACEB", 4),
|
||||
(208, "LEFTB", -1),
|
||||
(209, "RIGHTB", -1),
|
||||
(210, "MIDB", 3),
|
||||
(211, "LENB", 1),
|
||||
(212, "ROUNDUP", 2),
|
||||
(213, "ROUNDDOWN", 2),
|
||||
(214, "ASC", 1),
|
||||
(215, "DBCS", 1),
|
||||
(216, "RANK", -1),
|
||||
(219, "ADDRESS", -1),
|
||||
(220, "DAYS360", -1),
|
||||
(221, "TODAY", 0),
|
||||
(222, "VDB", -1),
|
||||
(223, "ELSE", 0),
|
||||
(224, "ELSE.IF", 1),
|
||||
(225, "END.IF", 0),
|
||||
(226, "FOR.CELL", -1),
|
||||
(227, "MEDIAN", -1),
|
||||
(228, "SUMPRODUCT", -1),
|
||||
(229, "SINH", 1),
|
||||
(230, "COSH", 1),
|
||||
(231, "TANH", 1),
|
||||
(232, "ASINH", 1),
|
||||
(233, "ACOSH", 1),
|
||||
(234, "ATANH", 1),
|
||||
(235, "DGET", 3),
|
||||
(236, "CREATE.OBJECT", -1),
|
||||
(237, "VOLATILE", -1),
|
||||
(238, "LAST.ERROR", 0),
|
||||
(239, "CUSTOM.UNDO", -1),
|
||||
(240, "CUSTOM.REPEAT", -1),
|
||||
(241, "FORMULA.CONVERT", -1),
|
||||
(242, "GET.LINK.INFO", -1),
|
||||
(243, "TEXT.BOX", -1),
|
||||
(244, "INFO", 1),
|
||||
(245, "GROUP", 0),
|
||||
(246, "GET.OBJECT", -1),
|
||||
(247, "DB", -1),
|
||||
(248, "PAUSE", -1),
|
||||
(251, "RESUME", -1),
|
||||
(252, "FREQUENCY", 2),
|
||||
(253, "ADD.TOOLBAR", -1),
|
||||
(254, "DELETE.TOOLBAR", 1),
|
||||
(256, "RESET.TOOLBAR", 1),
|
||||
(257, "EVALUATE", 1),
|
||||
(258, "GET.TOOLBAR", -1),
|
||||
(259, "GET.TOOL", -1),
|
||||
(260, "SPELLING.CHECK", -1),
|
||||
(261, "ERROR.TYPE", 1),
|
||||
(262, "APP.TITLE", -1),
|
||||
(263, "WINDOW.TITLE", -1),
|
||||
(264, "SAVE.TOOLBAR", -1),
|
||||
(265, "ENABLE.TOOL", 3),
|
||||
(266, "PRESS.TOOL", 3),
|
||||
(267, "REGISTER.ID", -1),
|
||||
(268, "GET.WORKBOOK", -1),
|
||||
(269, "AVEDEV", -1),
|
||||
(270, "BETADIST", -1),
|
||||
(271, "GAMMALN", 1),
|
||||
(272, "BETAINV", -1),
|
||||
(273, "BINOMDIST", 4),
|
||||
(274, "CHIDIST", 2),
|
||||
(275, "CHIINV", 2),
|
||||
(276, "COMBIN", 2),
|
||||
(277, "CONFIDENCE", 3),
|
||||
(278, "CRITBINOM", 3),
|
||||
(279, "EVEN", 1),
|
||||
(280, "EXPONDIST", 3),
|
||||
(281, "FDIST", 3),
|
||||
(282, "FINV", 3),
|
||||
(283, "FISHER", 1),
|
||||
(284, "FISHERINV", 1),
|
||||
(285, "FLOOR", 2),
|
||||
(286, "GAMMADIST", 4),
|
||||
(287, "GAMMAINV", 3),
|
||||
(288, "CEILING", 2),
|
||||
(289, "HYPGEOMDIST", 4),
|
||||
(290, "LOGNORMDIST", 3),
|
||||
(291, "LOGINV", 3),
|
||||
(292, "NEGBINOMDIST", 3),
|
||||
(293, "NORMDIST", 4),
|
||||
(294, "NORMSDIST", 1),
|
||||
(295, "NORMINV", 3),
|
||||
(296, "NORMSINV", 1),
|
||||
(297, "STANDARDIZE", 3),
|
||||
(298, "ODD", 1),
|
||||
(299, "PERMUT", 2),
|
||||
(300, "POISSON", 3),
|
||||
(301, "TDIST", 3),
|
||||
(302, "WEIBULL", 4),
|
||||
(303, "SUMXMY2", 2),
|
||||
(304, "SUMX2MY2", 2),
|
||||
(305, "SUMX2PY2", 2),
|
||||
(306, "CHITEST", 2),
|
||||
(307, "CORREL", 2),
|
||||
(308, "COVAR", 2),
|
||||
(309, "FORECAST", 3),
|
||||
(310, "FTEST", 2),
|
||||
(311, "INTERCEPT", 2),
|
||||
(312, "PEARSON", 2),
|
||||
(313, "RSQ", 2),
|
||||
(314, "STEYX", 2),
|
||||
(315, "SLOPE", 2),
|
||||
(316, "TTEST", 4),
|
||||
(317, "PROB", -1),
|
||||
(318, "DEVSQ", -1),
|
||||
(319, "GEOMEAN", -1),
|
||||
(320, "HARMEAN", -1),
|
||||
(321, "SUMSQ", -1),
|
||||
(322, "KURT", -1),
|
||||
(323, "SKEW", -1),
|
||||
(324, "ZTEST", -1),
|
||||
(325, "LARGE", 2),
|
||||
(326, "SMALL", 2),
|
||||
(327, "QUARTILE", 2),
|
||||
(328, "PERCENTILE", 2),
|
||||
(329, "PERCENTRANK", -1),
|
||||
(330, "MODE", -1),
|
||||
(331, "TRIMMEAN", 2),
|
||||
(332, "TINV", 2),
|
||||
(334, "MOVIE.COMMAND", -1),
|
||||
(335, "GET.MOVIE", -1),
|
||||
(336, "CONCATENATE", -1),
|
||||
(337, "POWER", 2),
|
||||
(338, "PIVOT.ADD.DATA", -1),
|
||||
(339, "GET.PIVOT.TABLE", -1),
|
||||
(340, "GET.PIVOT.FIELD", -1),
|
||||
(341, "GET.PIVOT.ITEM", -1),
|
||||
(342, "RADIANS", 1),
|
||||
(343, "DEGREES", 1),
|
||||
(344, "SUBTOTAL", -1),
|
||||
(345, "SUMIF", -1),
|
||||
(346, "COUNTIF", 2),
|
||||
(347, "COUNTBLANK", 1),
|
||||
(348, "SCENARIO.GET", -1),
|
||||
(349, "OPTIONS.LISTS.GET", 1),
|
||||
(350, "ISPMT", 4),
|
||||
(351, "DATEDIF", 3),
|
||||
(352, "DATESTRING", 1),
|
||||
(353, "NUMBERSTRING", 2),
|
||||
(354, "ROMAN", -1),
|
||||
(355, "OPEN.DIALOG", -1),
|
||||
(356, "SAVE.DIALOG", -1),
|
||||
(357, "VIEW.GET", -1),
|
||||
(358, "GETPIVOTDATA", -1),
|
||||
(359, "HYPERLINK", -1),
|
||||
(360, "PHONETIC", 1),
|
||||
(361, "AVERAGEA", -1),
|
||||
(362, "MAXA", -1),
|
||||
(363, "MINA", -1),
|
||||
(364, "STDEVPA", -1),
|
||||
(365, "VARPA", -1),
|
||||
(366, "STDEVA", -1),
|
||||
(367, "VARA", -1),
|
||||
(368, "BAHTTEXT", 1),
|
||||
(369, "THAIDAYOFWEEK", 1),
|
||||
(370, "THAIDIGIT", 1),
|
||||
(371, "THAIMONTHOFYEAR", 1),
|
||||
(372, "THAINUMSOUND", 1),
|
||||
(373, "THAINUMSTRING", 1),
|
||||
(374, "THAISTRINGLENGTH", 1),
|
||||
(375, "ISTHAIDIGIT", 1),
|
||||
(376, "ROUNDBAHTDOWN", 1),
|
||||
(377, "ROUNDBAHTUP", 1),
|
||||
(378, "THAIYEAR", 1),
|
||||
(379, "RTD", -1),
|
||||
];
|
||||
@@ -1,223 +0,0 @@
|
||||
//! BIFF8 record framing ([MS-XLS] 2.1.4) and the string structures ([MS-XLS] 2.5.240,
|
||||
//! 2.5.293, 2.5.294) that may continue across `Continue` records.
|
||||
|
||||
use super::invalid;
|
||||
use crate::IoError;
|
||||
|
||||
/// `Continue` record type.
|
||||
pub(super) const CONTINUE: u16 = 0x003C;
|
||||
/// Characters in one string at most (Excel's cell limit is 32,767; the SST may hold longer).
|
||||
const MAX_CHARS: usize = 1 << 20;
|
||||
/// `Continue` records gathered for one record at most.
|
||||
const MAX_PARTS: usize = 1 << 16;
|
||||
|
||||
/// One record and the payloads of the `Continue` records that follow it.
|
||||
pub(super) struct Record<'a> {
|
||||
pub id: u16,
|
||||
pub parts: Vec<&'a [u8]>,
|
||||
}
|
||||
|
||||
/// Iterates over the records of a stream starting at `pos`.
|
||||
pub(super) struct Records<'a> {
|
||||
stream: &'a [u8],
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> Records<'a> {
|
||||
pub fn new(stream: &'a [u8], pos: usize) -> Self {
|
||||
Records { stream, pos }
|
||||
}
|
||||
|
||||
fn raw(&mut self) -> Result<Option<(u16, &'a [u8])>, IoError> {
|
||||
if self.pos >= self.stream.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let head = self.stream.get(self.pos..self.pos.saturating_add(4)).ok_or_else(|| invalid("truncated record header"))?;
|
||||
let id = u16::from_le_bytes([head.first().copied().unwrap_or(0), head.get(1).copied().unwrap_or(0)]);
|
||||
let len = usize::from(u16::from_le_bytes([head.get(2).copied().unwrap_or(0), head.get(3).copied().unwrap_or(0)]));
|
||||
let start = self.pos + 4;
|
||||
let data = self.stream.get(start..start.saturating_add(len)).ok_or_else(|| invalid("truncated record"))?;
|
||||
self.pos = start + len;
|
||||
Ok(Some((id, data)))
|
||||
}
|
||||
|
||||
fn peek_id(&self) -> Option<u16> {
|
||||
let h = self.stream.get(self.pos..self.pos.checked_add(2)?)?;
|
||||
Some(u16::from_le_bytes([*h.first()?, *h.get(1)?]))
|
||||
}
|
||||
|
||||
/// The next record with its `Continue` records, or `None` at the end of the stream.
|
||||
pub fn next_record(&mut self) -> Result<Option<Record<'a>>, IoError> {
|
||||
let Some((id, data)) = self.raw()? else { return Ok(None) };
|
||||
let mut parts = vec![data];
|
||||
while self.peek_id() == Some(CONTINUE) && parts.len() < MAX_PARTS {
|
||||
if let Some((_, more)) = self.raw()? {
|
||||
parts.push(more);
|
||||
}
|
||||
}
|
||||
Ok(Some(Record { id, parts }))
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a record's payload, crossing into its `Continue` records when needed.
|
||||
pub(super) struct Cursor<'a, 'b> {
|
||||
parts: &'b [&'a [u8]],
|
||||
part: usize,
|
||||
pos: usize,
|
||||
/// Bytes in every part after the current one, kept up to date as parts advance, so
|
||||
/// `remaining()` is constant-time however many `Continue` records a string session crosses.
|
||||
after: usize,
|
||||
}
|
||||
|
||||
impl<'a, 'b> Cursor<'a, 'b> {
|
||||
pub fn new(parts: &'b [&'a [u8]]) -> Self {
|
||||
let after = parts.iter().skip(1).map(|p| p.len()).sum::<usize>();
|
||||
Cursor { parts, part: 0, pos: 0, after }
|
||||
}
|
||||
|
||||
/// Skips to the next part when the current one is used up. Returns whether a new part began.
|
||||
fn settle(&mut self) -> bool {
|
||||
let mut moved = false;
|
||||
while self.parts.get(self.part).is_some_and(|p| self.pos >= p.len()) && self.part + 1 < self.parts.len() {
|
||||
self.after = self.after.saturating_sub(self.parts[self.part + 1].len());
|
||||
self.part += 1;
|
||||
self.pos = 0;
|
||||
moved = true;
|
||||
}
|
||||
moved
|
||||
}
|
||||
|
||||
pub fn u8(&mut self) -> Result<u8, IoError> {
|
||||
self.settle();
|
||||
let b = self.parts.get(self.part).and_then(|p| p.get(self.pos)).copied().ok_or_else(|| invalid("record is shorter than its contents"))?;
|
||||
self.pos += 1;
|
||||
Ok(b)
|
||||
}
|
||||
|
||||
pub fn u16(&mut self) -> Result<u16, IoError> {
|
||||
Ok(u16::from_le_bytes([self.u8()?, self.u8()?]))
|
||||
}
|
||||
|
||||
pub fn u32(&mut self) -> Result<u32, IoError> {
|
||||
Ok(u32::from_le_bytes([self.u8()?, self.u8()?, self.u8()?, self.u8()?]))
|
||||
}
|
||||
|
||||
pub fn f64(&mut self) -> Result<f64, IoError> {
|
||||
let mut b = [0u8; 8];
|
||||
for x in b.iter_mut() {
|
||||
*x = self.u8()?;
|
||||
}
|
||||
Ok(f64::from_le_bytes(b))
|
||||
}
|
||||
|
||||
pub fn skip(&mut self, n: usize) -> Result<(), IoError> {
|
||||
for _ in 0..n {
|
||||
self.u8()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bytes left in the record (all parts), constant-time in the part count.
|
||||
pub fn remaining(&self) -> usize {
|
||||
let here = self.parts.get(self.part).map_or(0, |p| p.len().saturating_sub(self.pos));
|
||||
here.saturating_add(self.after)
|
||||
}
|
||||
|
||||
/// The rest of the record as one buffer.
|
||||
pub fn rest(&mut self) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(self.remaining());
|
||||
if let Some(p) = self.parts.get(self.part) {
|
||||
out.extend_from_slice(p.get(self.pos..).unwrap_or(&[]));
|
||||
}
|
||||
for p in self.parts.iter().skip(self.part + 1) {
|
||||
out.extend_from_slice(p);
|
||||
}
|
||||
self.part = self.parts.len();
|
||||
self.pos = 0;
|
||||
self.after = 0;
|
||||
out
|
||||
}
|
||||
|
||||
/// `cch` characters, compressed (one byte each) or UTF-16. When the characters run into
|
||||
/// a `Continue` record, that record starts with a fresh compression flag ([MS-XLS] 2.5.293).
|
||||
pub fn chars(&mut self, cch: usize, mut high: bool) -> Result<String, IoError> {
|
||||
if cch > MAX_CHARS {
|
||||
return Err(invalid("string too long"));
|
||||
}
|
||||
let mut units: Vec<u16> = Vec::with_capacity(cch.min(self.remaining()));
|
||||
while units.len() < cch {
|
||||
let at_end = self.parts.get(self.part).is_none_or(|p| self.pos >= p.len());
|
||||
if at_end && self.settle() {
|
||||
high = self.u8()? & 1 != 0;
|
||||
}
|
||||
units.push(if high { self.u16()? } else { u16::from(self.u8()?) });
|
||||
}
|
||||
Ok(String::from_utf16_lossy(&units))
|
||||
}
|
||||
|
||||
/// ShortXLUnicodeString: 1-byte count, flags, characters.
|
||||
pub fn short_string(&mut self) -> Result<String, IoError> {
|
||||
let cch = usize::from(self.u8()?);
|
||||
let high = self.u8()? & 1 != 0;
|
||||
self.chars(cch, high)
|
||||
}
|
||||
|
||||
/// XLUnicodeString: 2-byte count, flags, characters.
|
||||
pub fn string(&mut self) -> Result<String, IoError> {
|
||||
let cch = usize::from(self.u16()?);
|
||||
let high = self.u8()? & 1 != 0;
|
||||
self.chars(cch, high)
|
||||
}
|
||||
|
||||
/// XLUnicodeRichExtendedString: the text; formatting runs and phonetic data are skipped.
|
||||
pub fn rich_string(&mut self) -> Result<String, IoError> {
|
||||
let cch = usize::from(self.u16()?);
|
||||
let flags = self.u8()?;
|
||||
let runs = if flags & 0x08 != 0 { usize::from(self.u16()?) } else { 0 };
|
||||
let ext = if flags & 0x04 != 0 { self.u32()? as usize } else { 0 };
|
||||
let text = self.chars(cch, flags & 1 != 0)?;
|
||||
let extra = runs.saturating_mul(4).saturating_add(ext);
|
||||
if extra > self.remaining() {
|
||||
return Err(invalid("string formatting runs past the end of the record"));
|
||||
}
|
||||
self.skip(extra)?;
|
||||
Ok(text)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strings_continue_with_a_new_compression_flag() {
|
||||
// "ab" compressed, then "ç" and "d" as UTF-16 in a Continue part.
|
||||
let first: &[u8] = &[4, 0, 0, b'a', b'b'];
|
||||
let second: &[u8] = &[1, 0xE7, 0, b'd', 0];
|
||||
let parts = [first, second];
|
||||
let mut c = Cursor::new(&parts);
|
||||
assert_eq!(c.rich_string().unwrap(), "abçd");
|
||||
assert_eq!(c.remaining(), 0);
|
||||
let mut c = Cursor::new(&parts);
|
||||
assert!(c.chars(MAX_CHARS + 1, false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remaining_counts_every_part_wherever_the_cursor_is() {
|
||||
let parts: [&[u8]; 4] = [&[1, 2, 3], &[2, 3, 4, 5], &[3, 4, 5, 6, 7], &[4]];
|
||||
let mut c = Cursor::new(&parts);
|
||||
assert_eq!(c.remaining(), 13);
|
||||
for b in 1..=3u64 {
|
||||
assert_eq!(u64::from(c.u8().unwrap()), b, "part 0 in order");
|
||||
}
|
||||
assert_eq!(c.remaining(), 10, "after the first part is used up");
|
||||
for _ in 0..4 {
|
||||
c.u8().unwrap();
|
||||
}
|
||||
assert_eq!(c.remaining(), 6, "into the Continue parts");
|
||||
let mut c = Cursor::new(&parts);
|
||||
assert_eq!(u64::from(c.u8().unwrap()), 1);
|
||||
assert_eq!(c.rest().len(), 12);
|
||||
assert_eq!(c.remaining(), 0);
|
||||
}
|
||||
}
|
||||
@@ -1,406 +0,0 @@
|
||||
//! Cell formatting in a BIFF8 Workbook stream, from [MS-XLS]: Font (2.4.122), XF (2.4.353) with
|
||||
//! CellXF (2.5.91), Palette (2.4.188) and Style (2.4.269). Every XF is
|
||||
//! decoded on its own: a cell XF stores its complete formatting (the fAtr* bits only say whether
|
||||
//! later changes to the parent style propagate), so nothing is inherited from `ixfParent`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use gridcraft_model::style::Protection;
|
||||
use gridcraft_model::{
|
||||
Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, StyleId, StyleTable, Underline, VAlign,
|
||||
VertAlign, Workbook,
|
||||
};
|
||||
|
||||
use super::records::Cursor;
|
||||
use crate::tables::{INDEXED, builtin_format};
|
||||
|
||||
/// Font records kept at most ([MS-XLS] FontIndex allows indexes up to 1022).
|
||||
const MAX_FONTS: usize = 1024;
|
||||
/// Style records kept at most.
|
||||
const MAX_STYLES: usize = 65_536;
|
||||
/// Characters kept from a style name (Excel allows 255).
|
||||
const MAX_STYLE_NAME: usize = 255;
|
||||
|
||||
/// The automatic font colour (IcvFont 0x7FFF) and the system foreground/background colours.
|
||||
const ICV_FONT_AUTO: u16 = 0x7FFF;
|
||||
const ICV_DEFAULT_FG: u16 = 0x40;
|
||||
const ICV_DEFAULT_BG: u16 = 0x41;
|
||||
|
||||
/// One XF record, decoded as far as its bytes allow.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(super) struct Xf {
|
||||
ifnt: u16,
|
||||
ifmt: u16,
|
||||
flags: u16,
|
||||
/// The CellXF/StyleXF data: 14 bytes when complete.
|
||||
data: [u8; 14],
|
||||
/// Whether all 20 bytes were present.
|
||||
complete: bool,
|
||||
}
|
||||
|
||||
/// A Style record: the XF it names and its name.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct NamedStyle {
|
||||
ixf: u16,
|
||||
/// `Some(id)` for a built-in style (BuiltInStyle.istyBuiltIn), `None` for a user style.
|
||||
builtin: Option<u8>,
|
||||
/// A user style's name (empty for built-in styles).
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// Formatting records collected from the globals substream.
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct StyleRecords {
|
||||
fonts: Vec<Font>,
|
||||
font_colors: Vec<u16>,
|
||||
xfs: Vec<Xf>,
|
||||
/// Palette entries for colour indexes 8 and up (RRGGBB), when a Palette record is present.
|
||||
palette: Option<Vec<u32>>,
|
||||
styles: Vec<NamedStyle>,
|
||||
/// Records that were shorter than their structure; the parts present were used.
|
||||
pub(super) short: bool,
|
||||
}
|
||||
|
||||
impl StyleRecords {
|
||||
/// A Font record. The colour is resolved later, once the palette is known.
|
||||
pub(super) fn font(&mut self, c: &mut Cursor<'_, '_>) {
|
||||
if self.fonts.len() >= MAX_FONTS {
|
||||
return;
|
||||
}
|
||||
match read_font(c) {
|
||||
Some((font, icv)) => {
|
||||
self.fonts.push(font);
|
||||
self.font_colors.push(icv);
|
||||
}
|
||||
None => {
|
||||
// Keep the numbering of the fonts after it.
|
||||
self.short = true;
|
||||
self.fonts.push(Font::default());
|
||||
self.font_colors.push(ICV_FONT_AUTO);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An XF record. A short record keeps the fields it has.
|
||||
pub(super) fn xf(&mut self, c: &mut Cursor<'_, '_>, max: usize) {
|
||||
if self.xfs.len() >= max {
|
||||
return;
|
||||
}
|
||||
let mut xf = Xf::default();
|
||||
let head = (|| Some((c.u16().ok()?, c.u16().ok()?, c.u16().ok()?)))();
|
||||
match head {
|
||||
Some((ifnt, ifmt, flags)) => {
|
||||
xf.ifnt = ifnt;
|
||||
xf.ifmt = ifmt;
|
||||
xf.flags = flags;
|
||||
xf.complete = true;
|
||||
for b in xf.data.iter_mut() {
|
||||
match c.u8() {
|
||||
Ok(v) => *b = v,
|
||||
Err(_) => {
|
||||
xf.complete = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Not even the font and format: an unformatted, locked cell.
|
||||
xf.flags = 1;
|
||||
}
|
||||
}
|
||||
if !xf.complete {
|
||||
self.short = true;
|
||||
}
|
||||
self.xfs.push(xf);
|
||||
}
|
||||
|
||||
/// A Palette record: colours for indexes 8 onwards.
|
||||
pub(super) fn palette(&mut self, c: &mut Cursor<'_, '_>) {
|
||||
let Ok(ccv) = c.u16() else {
|
||||
self.short = true;
|
||||
return;
|
||||
};
|
||||
let n = usize::from(ccv).min(56);
|
||||
if usize::from(ccv) > c.remaining() / 4 {
|
||||
self.short = true;
|
||||
}
|
||||
let mut colors = Vec::with_capacity(n);
|
||||
for _ in 0..n {
|
||||
let (Ok(r), Ok(g), Ok(b), Ok(_)) = (c.u8(), c.u8(), c.u8(), c.u8()) else { break };
|
||||
colors.push((u32::from(r) << 16) | (u32::from(g) << 8) | u32::from(b));
|
||||
}
|
||||
self.palette = Some(colors);
|
||||
}
|
||||
|
||||
/// A Style record.
|
||||
pub(super) fn style(&mut self, c: &mut Cursor<'_, '_>) {
|
||||
if self.styles.len() >= MAX_STYLES {
|
||||
return;
|
||||
}
|
||||
let Ok(word) = c.u16() else {
|
||||
self.short = true;
|
||||
return;
|
||||
};
|
||||
let ixf = word & 0x0FFF;
|
||||
if word & 0x8000 != 0 {
|
||||
let Ok(id) = c.u8() else {
|
||||
self.short = true;
|
||||
return;
|
||||
};
|
||||
// BuiltInStyle: the style's number (0 is Normal) and outline level.
|
||||
self.styles.push(NamedStyle { ixf, builtin: Some(id), name: String::new() });
|
||||
} else {
|
||||
match c.string() {
|
||||
Ok(name) => {
|
||||
let name: String = name.chars().filter(|ch| *ch != '\0').take(MAX_STYLE_NAME).collect();
|
||||
if !name.is_empty() {
|
||||
self.styles.push(NamedStyle { ixf, builtin: None, name });
|
||||
}
|
||||
}
|
||||
Err(_) => self.short = true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The 64-entry colour table: the built-in eight, then the palette (or the default one).
|
||||
fn color_table(&self) -> [u32; 64] {
|
||||
let mut table = INDEXED;
|
||||
if let Some(p) = &self.palette {
|
||||
for (slot, c) in table.iter_mut().skip(8).zip(p.iter()) {
|
||||
*slot = *c;
|
||||
}
|
||||
}
|
||||
table
|
||||
}
|
||||
}
|
||||
|
||||
/// Font record fields up to the name; `None` if the record is too short for them.
|
||||
fn read_font(c: &mut Cursor<'_, '_>) -> Option<(Font, u16)> {
|
||||
let height = c.u16().ok()?;
|
||||
let flags = c.u16().ok()?;
|
||||
let icv = c.u16().ok()?;
|
||||
let weight = c.u16().ok()?;
|
||||
let script = c.u16().ok()?;
|
||||
let underline = c.u8().ok()?;
|
||||
let family = c.u8().ok()?;
|
||||
let charset = c.u8().ok()?;
|
||||
c.skip(1).ok()?; // reserved
|
||||
let name = c.short_string().ok()?;
|
||||
let mut f = Font::default();
|
||||
let name: String = name.chars().filter(|ch| *ch != '\0').take(255).collect();
|
||||
if !name.trim().is_empty() {
|
||||
f.name = name;
|
||||
}
|
||||
if height > 0 {
|
||||
f.size = (f32::from(height) / 20.0).clamp(1.0, 409.0);
|
||||
}
|
||||
f.italic = flags & 0x0002 != 0;
|
||||
f.strike = flags & 0x0008 != 0;
|
||||
// Weights run 100-1000; Excel writes 400 for normal and 700 for bold.
|
||||
f.bold = weight >= 600;
|
||||
f.vert = match script {
|
||||
1 => VertAlign::Superscript,
|
||||
2 => VertAlign::Subscript,
|
||||
_ => VertAlign::Baseline,
|
||||
};
|
||||
f.underline = match underline {
|
||||
0x01 => Underline::Single,
|
||||
0x02 => Underline::Double,
|
||||
0x21 => Underline::SingleAccounting,
|
||||
0x22 => Underline::DoubleAccounting,
|
||||
_ => Underline::None,
|
||||
};
|
||||
f.family = Some(family);
|
||||
f.charset = Some(charset);
|
||||
Some((f, icv))
|
||||
}
|
||||
|
||||
/// A colour index in an XF (IcvXF) or Font (IcvFont): 0-63 from the colour table, the system
|
||||
/// and automatic colours as automatic.
|
||||
fn color(icv: u16, table: &[u32; 64]) -> Color {
|
||||
match icv {
|
||||
ICV_DEFAULT_FG | ICV_DEFAULT_BG | ICV_FONT_AUTO => Color::Auto,
|
||||
_ => table.get(usize::from(icv)).map_or(Color::Auto, |c| Color::Rgb(*c)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A border line style from its BIFF8 code (dgLeft and the like); unknown codes are no border.
|
||||
fn border_style(dg: u32) -> BorderStyle {
|
||||
match dg {
|
||||
1 => BorderStyle::Thin,
|
||||
2 => BorderStyle::Medium,
|
||||
3 => BorderStyle::Dashed,
|
||||
4 => BorderStyle::Dotted,
|
||||
5 => BorderStyle::Thick,
|
||||
6 => BorderStyle::Double,
|
||||
7 => BorderStyle::Hair,
|
||||
8 => BorderStyle::MediumDashed,
|
||||
9 => BorderStyle::DashDot,
|
||||
10 => BorderStyle::MediumDashDot,
|
||||
11 => BorderStyle::DashDotDot,
|
||||
12 => BorderStyle::MediumDashDotDot,
|
||||
13 => BorderStyle::SlantDashDot,
|
||||
_ => BorderStyle::None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A fill pattern from its BIFF8 code (fls); unknown codes are no pattern.
|
||||
fn pattern(fls: u16) -> PatternType {
|
||||
match fls {
|
||||
0x01 => PatternType::Solid,
|
||||
0x02 => PatternType::Gray50,
|
||||
0x03 => PatternType::Gray75,
|
||||
0x04 => PatternType::Gray25,
|
||||
0x05 => PatternType::DarkHorizontal,
|
||||
0x06 => PatternType::DarkVertical,
|
||||
0x07 => PatternType::DarkDown,
|
||||
0x08 => PatternType::DarkUp,
|
||||
0x09 => PatternType::DarkGrid,
|
||||
0x0A => PatternType::DarkTrellis,
|
||||
0x0B => PatternType::LightHorizontal,
|
||||
0x0C => PatternType::LightVertical,
|
||||
0x0D => PatternType::LightDown,
|
||||
0x0E => PatternType::LightUp,
|
||||
0x0F => PatternType::LightGrid,
|
||||
0x10 => PatternType::LightTrellis,
|
||||
0x11 => PatternType::Gray125,
|
||||
0x12 => PatternType::Gray0625,
|
||||
_ => PatternType::None,
|
||||
}
|
||||
}
|
||||
|
||||
/// One border side: its style and colour; a side without a style has no colour.
|
||||
fn line(dg: u32, icv: u32, table: &[u32; 64]) -> BorderLine {
|
||||
let style = border_style(dg);
|
||||
if style == BorderStyle::None {
|
||||
return BorderLine::default();
|
||||
}
|
||||
// Colour 0 means "not specified": automatic.
|
||||
let color = if icv == 0 { Color::Auto } else { color(icv as u16, table) };
|
||||
BorderLine { style, color }
|
||||
}
|
||||
|
||||
/// The font an XF names (FontIndex: index 4 is never written, so 5 and up count from one).
|
||||
fn xf_font(ifnt: u16, fonts: &[Font]) -> Font {
|
||||
let i = match ifnt {
|
||||
0..=3 => usize::from(ifnt),
|
||||
4 => return fonts.first().cloned().unwrap_or_default(),
|
||||
_ => usize::from(ifnt) - 1,
|
||||
};
|
||||
fonts.get(i).or_else(|| fonts.first()).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The number format an XF names: a custom format, else a built-in one, else General.
|
||||
fn num_fmt(ifmt: u16, formats: &HashMap<u16, String>) -> NumFmt {
|
||||
let code = formats.get(&ifmt).map(String::as_str).or_else(|| builtin_format(u32::from(ifmt))).unwrap_or("General");
|
||||
if code.eq_ignore_ascii_case("General") { NumFmt::default() } else { NumFmt::new(code) }
|
||||
}
|
||||
|
||||
/// The model style of one XF.
|
||||
fn xf_style(xf: &Xf, fonts: &[Font], formats: &HashMap<u16, String>, table: &[u32; 64]) -> Style {
|
||||
let mut s = Style {
|
||||
font: xf_font(xf.ifnt, fonts),
|
||||
num_fmt: num_fmt(xf.ifmt, formats),
|
||||
protection: Protection { locked: xf.flags & 0x0001 != 0, hidden: xf.flags & 0x0002 != 0 },
|
||||
..Style::default()
|
||||
};
|
||||
if !xf.complete {
|
||||
return s;
|
||||
}
|
||||
let d = &xf.data;
|
||||
let byte = |i: usize| d.get(i).copied().unwrap_or(0);
|
||||
let word32 = |i: usize| u32::from_le_bytes([byte(i), byte(i + 1), byte(i + 2), byte(i + 3)]);
|
||||
let (a0, rot, a2) = (byte(0), byte(1), byte(2));
|
||||
s.align = Alignment {
|
||||
h: match a0 & 0x07 {
|
||||
1 => HAlign::Left,
|
||||
2 => HAlign::Center,
|
||||
3 => HAlign::Right,
|
||||
4 => HAlign::Fill,
|
||||
5 => HAlign::Justify,
|
||||
6 => HAlign::CenterAcross,
|
||||
7 => HAlign::Distributed,
|
||||
_ => HAlign::General,
|
||||
},
|
||||
wrap: a0 & 0x08 != 0,
|
||||
v: match (a0 >> 4) & 0x07 {
|
||||
0 => VAlign::Top,
|
||||
1 => VAlign::Center,
|
||||
3 => VAlign::Justify,
|
||||
4 => VAlign::Distributed,
|
||||
_ => VAlign::Bottom,
|
||||
},
|
||||
rotation: match rot {
|
||||
0..=90 => i16::from(rot),
|
||||
91..=180 => -(i16::from(rot) - 90),
|
||||
255 => 255,
|
||||
_ => 0,
|
||||
},
|
||||
indent: a2 & 0x0F,
|
||||
shrink: a2 & 0x10 != 0,
|
||||
reading_order: None,
|
||||
};
|
||||
let b1 = word32(4);
|
||||
let b2 = word32(8);
|
||||
let mut borders = Borders {
|
||||
left: line(b1 & 0x0F, (b1 >> 16) & 0x7F, table),
|
||||
right: line((b1 >> 4) & 0x0F, (b1 >> 23) & 0x7F, table),
|
||||
top: line((b1 >> 8) & 0x0F, b2 & 0x7F, table),
|
||||
bottom: line((b1 >> 12) & 0x0F, (b2 >> 7) & 0x7F, table),
|
||||
..Borders::default()
|
||||
};
|
||||
let diag = line((b2 >> 21) & 0x0F, (b2 >> 14) & 0x7F, table);
|
||||
let which = (b1 >> 30) & 0x03;
|
||||
if which & 1 != 0 {
|
||||
borders.diag_down = diag;
|
||||
}
|
||||
if which & 2 != 0 {
|
||||
borders.diag_up = diag;
|
||||
}
|
||||
s.borders = borders;
|
||||
let fill = u16::from_le_bytes([byte(12), byte(13)]);
|
||||
let fls = ((b2 >> 26) & 0x3F) as u16;
|
||||
let fg = color(fill & 0x7F, table);
|
||||
let bg = color((fill >> 7) & 0x7F, table);
|
||||
s.fill = match pattern(fls) {
|
||||
PatternType::None => Fill::default(),
|
||||
PatternType::Solid => Fill::solid(fg),
|
||||
p => Fill { pattern: p, fg, bg },
|
||||
};
|
||||
s
|
||||
}
|
||||
|
||||
/// Imports the formatting: sets the workbook's default (Normal) font and named cell styles, and
|
||||
/// returns the model style of every XF in file order.
|
||||
pub(super) fn build(r: &StyleRecords, formats: &HashMap<u16, String>, wb: &mut Workbook) -> Vec<StyleId> {
|
||||
let table = r.color_table();
|
||||
let fonts: Vec<Font> = r.fonts.iter().zip(r.font_colors.iter()).map(|(f, icv)| Font { color: color(*icv, &table), ..f.clone() }).collect();
|
||||
// The Normal style's font is what every unformatted cell shows.
|
||||
let normal_xf = r.styles.iter().find(|s| s.builtin == Some(0)).map_or(0, |s| usize::from(s.ixf));
|
||||
let normal_font = r.xfs.get(normal_xf).map(|xf| xf_font(xf.ifnt, &fonts)).or_else(|| fonts.first().cloned());
|
||||
if let Some(font) = normal_font {
|
||||
wb.styles = StyleTable::with_default(Style { font, ..Style::default() });
|
||||
}
|
||||
let out = r.xfs.iter().map(|xf| wb.styles.intern(xf_style(xf, &fonts, formats, &table))).collect();
|
||||
// User-defined named styles. Excel 2007 and later write every built-in style into each .xls
|
||||
// (about fifty, their colours approximated to the palette); GridCraft defines those itself.
|
||||
let mut named: Vec<(String, Style)> = Vec::new();
|
||||
// Names are compared ASCII-case-insensitively; a set keeps a hostile file's many names linear.
|
||||
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::from(["normal".to_string()]);
|
||||
for ns in &r.styles {
|
||||
if ns.builtin.is_some() {
|
||||
continue;
|
||||
}
|
||||
let key = ns.name.to_ascii_lowercase();
|
||||
if seen.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
if let Some(xf) = r.xfs.get(usize::from(ns.ixf)) {
|
||||
seen.insert(key);
|
||||
named.push((ns.name.clone(), xf_style(xf, &fonts, formats, &table)));
|
||||
}
|
||||
}
|
||||
wb.cell_styles = named;
|
||||
out
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity
|
||||
|
||||
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-03 · **Change:** minor (form controls: buttons, scroll bars and spin buttons read and written; earlier: a web page's first table opens as a sheet, also when the page is named `.xls`; earlier: `workbookPr filterPrivacy` kept, and honoured on save; earlier: a workbook's R1C1 reference style, `calcPr refMode`, is kept through open and save; earlier: XLSX page margins are written as their decimals, `0.3` rather than `0.30000001192092896`; earlier: Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-12 · **Change:** minor (.xls is read by the shared craft-xls crate: the workbook opens on the sheet it was saved on, and rich-text (RString) cells are read; earlier: form controls: buttons, scroll bars and spin buttons read and written; earlier: a web page's first table opens as a sheet, also when the page is named `.xls`; earlier: `workbookPr filterPrivacy` kept, and honoured on save; earlier: a workbook's R1C1 reference style, `calcPr refMode`, is kept through open and save; earlier: XLSX page margins are written as their decimals, `0.3` rather than `0.30000001192092896`; earlier: Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
|
||||
Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests.
|
||||
Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h**
|
||||
@@ -161,6 +161,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-12 | minor | .xls import reads the file with craft-xls (storytold/craft-libs), the reader that moved there from this repository; GridCraft keeps the mapping onto its model. The workbook now opens on the sheet it was saved on (WINDOW1 `itabCur`), falling back to the first visible sheet as before (from #3551), and cells saved as RString records (text with formatting runs) are read as text instead of skipped |
|
||||
| 2026-11-10 | minor | `.xls` import: every formula except a Ctrl+Shift+Enter array is read as a legacy one with Excel's implicit `@` (a title row `=Other!A1:G1` shows its first cell instead of `#SPILL!`), one-cell Ctrl+Shift+Enter arrays stay arrays, and `@` of a 2-D range meets the formula's row and column (#410, PR #1367) |
|
||||
| 2026-11-02 | minor | XLSX: buttons, scroll bars and spin buttons read and written (control properties parts, `<controls>`, VML `x:ClientData`); other form controls stay with pass-through; a button's command, fixed input or output cell travels in a hidden defined name and comes back only while the file still assigns the button's macro (or none, for a command) |
|
||||
| 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) |
|
||||
|
||||
Reference in New Issue
Block a user