mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-12 17:02:08 -06:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4deedbb549 | ||
|
|
3d31729de3 | ||
|
|
0407b63663 | ||
|
|
1bd4ca8946 | ||
|
|
21df49c636 | ||
|
|
7e0d96d74b |
Generated
+42
-8
@@ -907,15 +907,20 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-cfb"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
|
||||
[[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 +943,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 +954,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 +982,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 +1009,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 +1020,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 +1029,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 +1045,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"
|
||||
@@ -2241,6 +2264,7 @@ name = "gridcraft-xlsx"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"craft-xls",
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
"gridcraft-functions",
|
||||
@@ -2992,6 +3016,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"
|
||||
|
||||
+7
-4
@@ -26,10 +26,13 @@ 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"] }
|
||||
# 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 +75,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"] }
|
||||
|
||||
@@ -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,7 @@ gridcraft-core = { workspace = true }
|
||||
gridcraft-formula = { workspace = true }
|
||||
gridcraft-math = { workspace = true }
|
||||
gridcraft-model = { 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> {
|
||||
|
||||
@@ -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