Merge upstream main into fix/formula-arg-count-363

Claude-Session: https://claude.ai/code/session_01BGBBGBC2gRpQ7GDuwQ6WPt
This commit is contained in:
Shirish Veerabattini 2026-10-11 15:20:49 -05:00
commit 9b59966cc9
109 changed files with 10111 additions and 391 deletions

2
Cargo.lock generated
View File

@ -1589,6 +1589,7 @@ version = "0.5.0"
dependencies = [
"egui",
"egui_kittest",
"epaint_default_fonts",
"gridcraft-chart",
"gridcraft-engine",
"image",
@ -1599,6 +1600,7 @@ dependencies = [
"sys-locale",
"toml",
"web-sys",
"zbus",
]
[[package]]

View File

@ -49,6 +49,8 @@ sys-locale = "0.3"
# Font parser egui's epaint uses; the UI validates system font files with it before handing them
# to egui, which panics on a file it can't parse. Same version as epaint's, so no second copy.
skrifa = { version = "0.44", default-features = false, features = ["std"] }
# Real font bytes for tests (Ubuntu Light, compiled into epaint).
epaint_default_fonts = "0.36"
[workspace.lints.rust]
unsafe_code = "forbid"

View File

@ -84,6 +84,7 @@
- **Compatible.** XLSX is the native format: styles, themes, formulas (including dynamic arrays),
tables, conditional formats, validation, comments, hyperlinks, charts, pictures, sparklines,
PivotTables and print settings round-trip. CSV and TSV too.
Excel 97-2003 `.xls` workbooks open with their values, formulas and number formats (formatting and objects not yet).
[XLSB worksheet data can be imported](docs/xlsb-import.md), using saved values instead of formulas.
[ODS worksheet data can be imported](docs/ods-import.md), using saved values instead of formulas.
[Static pictures can live inside cells](docs/cell-pictures.md), fitting their size and moving with the data.
@ -241,6 +242,11 @@ the first renderer fails to start, it tries the other one once. The log file rec
(lines starting with `graphics:`). `WGPU_BACKEND` still overrides it (and then nothing else is
tried), e.g. `$env:WGPU_BACKEND = "gl"` or `"dx12"`.
DirectX 12 shaders are compiled with FXC, which is part of Windows, so no `dxcompiler.dll` or
`dxil.dll` is needed, and a copy another program put on `PATH` can no longer stop the app at start
("Parent device is lost", #339). `$env:WGPU_DX12_COMPILER = "dxc"` (or `"auto"`) selects wgpu's DXC
handling again for troubleshooting.
### macOS
| Build | File | Notes |

View File

@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (community merges: worksheet functions 513 / 523, catalog 271 / 290) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (community merges: worksheet functions 513 / 523, catalog 274 / 290) · **Target:** Microsoft Excel (Microsoft 365)
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
@ -21,7 +21,7 @@ This page is the summary. The detail is in:
| Number | Value | Kind |
|---|---|---|
| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 271 / 290 (93.4%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) |
| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 274 / 290 (94.5%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) |
| **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) |
| **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) |
| **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) |
@ -132,6 +132,20 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-10-11 | Text boxes keep their formatting: run size, bold, italic, typeface, text colour and outline, alignment, vertical anchor and "no outline" (#377) |
| 2026-10-11 | Desktop printing opens the print PDF in the system viewer; Save As offers CSV, web page and PDF; macOS File › Properties opens File › Info (#370, #343) |
| 2026-10-11 | Windows: DirectX 12 shaders compile with FXC, so a missing or stray `dxcompiler.dll` no longer stops the app at start (#339, #354) |
| 2026-10-11 | Merged cells draw their whole outline: right and bottom edges come from the last column's and last row's cells (#376) |
| 2026-10-11 | Shrink to fit draws too-wide text and numbers in a smaller font inside the cell or merged area (#375) |
| 2026-10-11 | Shift+Space and Ctrl+Space select the row / column without starting an edit; Control+Space on Mac (#338) |
| 2026-10-11 | Workbooks reopen at their saved scroll position (`topLeftCell`, also with frozen panes) and save the current one back (#378) |
| 2026-10-11 | Protect Sheet passwords use Excel-compatible hashes and round-trip in XLSX `sheetProtection` (imported passwords verify instead of being dropped) |
| 2026-10-11 | Review › Protect Workbook opens a dialog with an optional password (Excel-compatible hash, kept in XLSX `workbookProtection`); unprotecting verifies it (#345) |
| 2026-10-11 | Formula bar, name box and dialogs fall back to the cell font: Hebrew (and any script cells can show) no longer turns into boxes there (#349) |
| 2026-10-11 | Headers and footers: first-page and odd/even variants are read, kept, printed and edited in Page Layout view; Insert › Header & Footer edits the header in place (#344) |
| 2026-10-11 | Legacy Excel 97-2003 `.xls` (BIFF8) import with formulas, number formats, merges, sizes and names (#121) |
| 2026-10-11 | XLSX pass-through: custom XML, external links, connections, query tables, printer settings, form/ActiveX controls and OLE objects are kept on save (#330, #324) |
| 2026-10-11 | Data › Forecast › Forecast Sheet: a new sheet with the history, FORECAST.ETS forecast, confidence bounds, statistics and a chart (#322) |
| 2026-10-11 | Startup no longer panics on a system font egui can't parse: fonts are validated and skipped (#226) |
| 2026-10-11 | Ctrl+drag on the fill handle swaps copying and extending a series (#264) |
| 2026-10-11 | Odd-period bond functions ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD: Financial 55 / 55 (#275) |
@ -161,6 +175,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Forecast Sheet (#322); progress log row |
| 2026-10-11 | minor | Worksheet functions 505 / 523 (ODD* bond functions); progress log row |
| 2026-10-11 | minor | Worksheet functions 503 / 523 (PERCENTOF, EUROCONVERT); progress log row |
| 2026-10-11 | minor | Worksheet functions 502 / 523 (BAHTTEXT); progress log row |

View File

@ -26,4 +26,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator"] }

View File

@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>GridCraft</title>
<link data-trunk rel="icon" href="../../assets/app-icon/gridcraft.ico" />
<link data-trunk rel="rust" data-bin="gridcraft-web" data-wasm-opt="z" data-weak-refs />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #f3f3f3; }

View File

@ -3,6 +3,7 @@
use gridcraft_engine::Session;
use gridcraft_ui_egui::{Inbox, Services, SheetApp};
use wasm_bindgen::JsCast as _;
use wasm_bindgen::JsValue;
const CANVAS_ID: &str = "gridcraft_canvas";
const LOADING_ID: &str = "gridcraft_loading";
@ -38,6 +39,7 @@ pub fn start() {
let _ = session.execute("file.new", serde_json::json!({"sample": "sales"}));
}
let mut app = SheetApp::new(session, services(inbox.clone(), cc.egui_ctx.clone()));
app.ui.system_theme = true;
// Follow the browser's language, except a CJK one: the web build has no system
// CJK fonts (none are bundled), so that interface would render as tofu.
let browser = gridcraft_ui_egui::i18n::Language::system();
@ -109,7 +111,7 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let Some(file) = rfd::AsyncFileDialog::new()
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xlsb", "ods", "csv", "tsv", "txt", "json"])
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "json"])
.pick_file()
.await
else {
@ -136,6 +138,11 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
log::error!("download of {name} failed: {e}");
}
})),
print: Some(Box::new(|bytes: &[u8]| {
if let Err(e) = print_pdf(bytes) {
log::error!("print failed: {e}");
}
})),
open_url: Some(Box::new(|url: &str| {
if let Some(w) = web_sys::window() {
let _ = w.open_with_url_and_target(url, "_blank");
@ -179,17 +186,184 @@ fn copy_html(html: &str, text: &str) -> Result<(), String> {
Ok(())
}
/// Triggers a browser download of `bytes`.
fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
/// Opens `bytes` (a PDF) as an object URL in a hidden iframe and calls the iframe's own
/// `print()` once it has finished loading — the standard way to drive the browser's native print
/// dialog on a PDF without a download or a popup window the browser might block. The blob URL and
/// the iframe are both cleaned up once the print dialog closes, so repeated prints don't leak
/// them.
fn print_pdf(bytes: &[u8]) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
let window = web_sys::window().ok_or("no window")?;
let document = window.document().ok_or("no document")?;
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
let opts = web_sys::BlobPropertyBag::new();
opts.set_type(if name.ends_with(".csv") { "text/csv" } else { "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
opts.set_type("application/pdf");
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?;
let iframe: web_sys::HtmlIFrameElement = document.create_element("iframe").map_err(js)?.dyn_into().map_err(|_| "not an iframe")?;
iframe.style().set_property("display", "none").map_err(js)?;
document.body().ok_or("no body")?.append_child(&iframe).map_err(js)?;
// `onload` fires once the PDF has actually rendered inside the iframe;
// printing before that would show a blank page. Set the handler BEFORE
// `src` so a fast/cached load can't fire before we're listening.
let iframe_for_load = iframe.clone();
let url_for_cleanup = url.clone();
let on_load = wasm_bindgen::closure::Closure::once_into_js(move || {
let Some(content_window) = iframe_for_load.content_window() else { return };
let _ = content_window.print();
// `afterprint` fires once the print dialog (or print preview) closes, whether the user
// printed or cancelled — the right moment to release the blob URL and drop the iframe.
let iframe_for_cleanup = iframe_for_load.clone();
let on_after_print = wasm_bindgen::closure::Closure::once_into_js(move || {
web_sys::Url::revoke_object_url(&url_for_cleanup).ok();
iframe_for_cleanup.remove();
});
let _ = content_window.add_event_listener_with_callback("afterprint", on_after_print.unchecked_ref());
});
iframe.set_onload(Some(on_load.unchecked_ref()));
iframe.set_src(&url);
Ok(())
}
fn mime_for(name: &str) -> &'static str {
match name.rsplit('.').next().map(str::to_ascii_lowercase).as_deref() {
Some("xlsx") => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
Some("xlsm") => "application/vnd.ms-excel.sheet.macroEnabled.12",
Some("xlsb") => "application/vnd.ms-excel.sheet.binary.macroEnabled.12",
Some("ods") => "application/vnd.oasis.opendocument.spreadsheet",
Some("csv") => "text/csv",
Some("tsv" | "txt") => "text/plain",
Some("pdf") => "application/pdf",
Some("html" | "htm") => "text/html",
Some("json") => "application/json",
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
_ => "application/octet-stream",
}
}
/// Trigger a browser save dialog or download of `bytes` named after `name`.
fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
let window = web_sys::window().ok_or("no window")?;
let show_picker_fn: Option<js_sys::Function> =
js_sys::Reflect::get(&window, &JsValue::from_str("showSaveFilePicker")).ok().and_then(|v| v.dyn_into().ok());
if let Some(picker_fn) = show_picker_fn {
let ext = std::path::Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or("xlsx").to_ascii_lowercase();
let dot_ext = format!(".{ext}");
let mime = mime_for(name);
let accept_obj = js_sys::Object::new();
let exts_arr = js_sys::Array::new();
exts_arr.push(&JsValue::from_str(&dot_ext));
let _ = js_sys::Reflect::set(&accept_obj, &JsValue::from_str(mime), &exts_arr);
let type_desc = match ext.as_str() {
"xlsx" => "Excel Workbook (*.xlsx)",
"xlsm" => "Excel Macro-Enabled Workbook (*.xlsm)",
"xlsb" => "Excel Binary Workbook (*.xlsb)",
"ods" => "OpenDocument Spreadsheet (*.ods)",
"csv" => "CSV (Comma delimited) (*.csv)",
"tsv" => "Tab Delimited Text (*.tsv)",
"txt" => "Plain Text (*.txt)",
"pdf" => "PDF Document (*.pdf)",
"html" | "htm" => "HTML Document (*.html)",
"json" => "JSON Document (*.json)",
"png" => "PNG Image (*.png)",
"jpg" | "jpeg" => "JPEG Image (*.jpeg)",
_ => "Spreadsheet",
};
let type_obj = js_sys::Object::new();
let _ = js_sys::Reflect::set(&type_obj, &JsValue::from_str("description"), &JsValue::from_str(type_desc));
let _ = js_sys::Reflect::set(&type_obj, &JsValue::from_str("accept"), &accept_obj);
let types_arr = js_sys::Array::new();
types_arr.push(&type_obj);
let picker_opts = js_sys::Object::new();
let _ = js_sys::Reflect::set(&picker_opts, &JsValue::from_str("suggestedName"), &JsValue::from_str(name));
let _ = js_sys::Reflect::set(&picker_opts, &JsValue::from_str("types"), &types_arr);
let prom_val = picker_fn.call1(&window, &picker_opts).map_err(js)?;
let prom: js_sys::Promise = prom_val.dyn_into().map_err(|_| "not a promise".to_string())?;
let bytes_vec = bytes.to_vec();
let name_owned = name.to_string();
// Anything but the user cancelling (no user gesture, a denied permission, a failed write)
// falls back to an ordinary download, so a save never fails silently.
let fallback = move |bytes: &[u8]| {
if let Some(w) = web_sys::window()
&& let Err(e) = trigger_anchor_download(&w, &name_owned, bytes)
{
log::error!("download fallback failed: {e}");
}
};
wasm_bindgen_futures::spawn_local(async move {
match wasm_bindgen_futures::JsFuture::from(prom).await {
Ok(handle_val) => {
if let Err(e) = write_file_handle(&handle_val, &bytes_vec).await {
log::error!("failed writing to file handle: {e}");
fallback(&bytes_vec);
} else {
log::info!("Successfully saved file through save file picker");
}
}
Err(e) => {
let err_name = js_sys::Reflect::get(&e, &JsValue::from_str("name")).ok().and_then(|v| v.as_string()).unwrap_or_default();
if err_name == "AbortError" {
log::info!("Save file dialog cancelled by user");
} else {
log::warn!("showSaveFilePicker refused ({err_name}); downloading instead");
fallback(&bytes_vec);
}
}
}
});
return Ok(());
}
// Fallback for browsers without showSaveFilePicker (e.g. Firefox, Safari):
trigger_anchor_download(&window, name, bytes)
}
async fn write_file_handle(handle: &JsValue, bytes: &[u8]) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
let create_fn: js_sys::Function = js_sys::Reflect::get(handle, &JsValue::from_str("createWritable"))
.map_err(js)?
.dyn_into()
.map_err(|_| "createWritable is not a function".to_string())?;
let stream_prom: js_sys::Promise = create_fn.call0(handle).map_err(js)?.dyn_into().map_err(|_| "not a promise".to_string())?;
let stream = wasm_bindgen_futures::JsFuture::from(stream_prom).await.map_err(js)?;
let write_fn: js_sys::Function =
js_sys::Reflect::get(&stream, &JsValue::from_str("write")).map_err(js)?.dyn_into().map_err(|_| "write is not a function".to_string())?;
let uint8 = js_sys::Uint8Array::from(bytes);
let write_prom: js_sys::Promise = write_fn.call1(&stream, &uint8).map_err(js)?.dyn_into().map_err(|_| "not a promise".to_string())?;
wasm_bindgen_futures::JsFuture::from(write_prom).await.map_err(js)?;
let close_fn: js_sys::Function =
js_sys::Reflect::get(&stream, &JsValue::from_str("close")).map_err(js)?.dyn_into().map_err(|_| "close is not a function".to_string())?;
let close_prom: js_sys::Promise = close_fn.call0(&stream).map_err(js)?.dyn_into().map_err(|_| "not a promise".to_string())?;
wasm_bindgen_futures::JsFuture::from(close_prom).await.map_err(js)?;
Ok(())
}
fn trigger_anchor_download(window: &web_sys::Window, name: &str, bytes: &[u8]) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
let document = window.document().ok_or("no document")?;
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
let opts = web_sys::BlobPropertyBag::new();
opts.set_type(mime_for(name));
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor".to_string())?;
a.set_href(&url);
a.set_download(name);
a.style().set_property("display", "none").map_err(js)?;

View File

@ -3,11 +3,15 @@
//! Vulkan is never initialized unless `WGPU_BACKEND` asks for it: enumerating Vulkan adapters
//! crashes inside Intel's `igvk64.dll` on some UHD Graphics drivers (#120), and that access
//! violation happens in the driver, where no Rust error handling can catch it.
//!
//! DirectX 12 shaders are compiled with FXC (`d3dcompiler_47.dll`, part of Windows), never with
//! whichever `dxcompiler.dll` the DLL search path happens to find (#339, #354): see
//! [`dx12_compiler`].
use eframe::{
NativeOptions,
egui_wgpu::WgpuSetup,
wgpu::{Backends, DeviceType},
wgpu::{Backends, DeviceType, Dx12Compiler},
};
/// What the start-up probe learned about one DirectX 12 adapter.
@ -59,9 +63,24 @@ pub fn fallback_backends(backend_override: Option<Backends>, first: Backends) ->
(!rest.is_empty()).then_some(rest)
}
/// Restricts the wgpu instance eframe creates to `backends`.
/// The DirectX 12 shader compiler: `env_choice` (normally `Dx12Compiler::from_env()`, i.e.
/// `WGPU_DX12_COMPILER`, for troubleshooting) or else FXC.
///
/// wgpu's default (`Auto`) loads the first `dxcompiler.dll` on the DLL search path. GridCraft ships
/// none, so that is another program's copy (a Windows SDK, an Edge or WebView folder, anything on
/// `PATH`). Without a matching `dxil.dll` beside it, DXC prints "DXIL signing library not found",
/// wgpu treats that warning as a compile error, its internal compute pipelines fail, the device is
/// lost and the app quits before the first frame ("Parent device is lost"). FXC is part of Windows
/// and needs no extra DLL; egui's shaders don't need anything newer.
pub fn dx12_compiler(env_choice: Option<Dx12Compiler>) -> Dx12Compiler {
env_choice.unwrap_or(Dx12Compiler::Fxc)
}
/// Restricts the wgpu instance eframe creates to `backends`, and pins its DirectX 12 shader
/// compiler ([`dx12_compiler`]).
pub fn configure_backends(options: &mut NativeOptions, backends: Backends) {
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
create.instance_descriptor.backend_options.dx12.shader_compiler = dx12_compiler(Dx12Compiler::from_env());
// eframe's default includes Vulkan and GL. Creating the GL backend can crash inside AMD's
// atio6axx.dll, and enumerating Vulkan adapters inside Intel's igvk64.dll (#120), before
// adapter selection or any Rust error handling runs, so the window never appears.
@ -189,4 +208,25 @@ mod tests {
// Both were already tried: nothing left to fall back to.
assert_eq!(fallback_backends(None, Backends::DX12 | Backends::GL), None);
}
#[test]
fn dx12_compiles_shaders_with_fxc_unless_asked() {
// #339, #354: wgpu's `Auto` would load a stray dxcompiler.dll and lose the device.
assert!(matches!(dx12_compiler(None), Dx12Compiler::Fxc));
assert!(matches!(dx12_compiler(Some(Dx12Compiler::Auto)), Dx12Compiler::Auto));
assert!(matches!(dx12_compiler(Some(Dx12Compiler::default_dynamic_dxc())), Dx12Compiler::DynamicDxc { .. }));
// eframe's own default is `Auto`; the configured instance (first start and retry) uses FXC.
let WgpuSetup::CreateNew(eframe_default) = NativeOptions::default().wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
if Dx12Compiler::from_env().is_none() {
assert!(matches!(eframe_default.instance_descriptor.backend_options.dx12.shader_compiler, Dx12Compiler::Auto));
let mut options = NativeOptions::default();
configure_backends(&mut options, Backends::DX12);
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
assert!(matches!(create.instance_descriptor.backend_options.dx12.shader_compiler, Dx12Compiler::Fxc));
}
}
}

View File

@ -150,8 +150,9 @@ fn services() -> Services {
})),
pick_open: Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xlsb", "ods", "csv", "tsv", "txt", "json"])
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "json"])
.add_filter("Excel Workbook", &["xlsx", "xlsm"])
.add_filter("Excel 97-2003 Workbook (import)", &["xls"])
.add_filter("Excel Binary Workbook (data import)", &["xlsb"])
.add_filter("OpenDocument Spreadsheet (data import)", &["ods"])
.add_filter("CSV", &["csv"])
@ -165,29 +166,70 @@ fn services() -> Services {
.and_then(|p| p.to_str().map(str::to_string))
})),
pick_save: Some(Box::new(|suggested: &str| {
let stem = std::path::Path::new(suggested).file_stem().and_then(|s| s.to_str()).unwrap_or("Book1").to_string();
rfd::FileDialog::new()
.set_file_name(format!("{stem}.xlsx"))
.add_filter("Excel Workbook (.xlsx)", &["xlsx"])
.add_filter("CSV UTF-8 (.csv)", &["csv"])
.add_filter("Web Page (.html)", &["html"])
.save_file()
.and_then(|p| p.to_str().map(str::to_string))
let path = std::path::Path::new(suggested);
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("Book1").to_string();
// The suggested name's type (an export asks for .pdf, .csv or .html) comes first.
const TYPES: [(&str, &str); 4] =
[("Excel Workbook (.xlsx)", "xlsx"), ("CSV UTF-8 (.csv)", "csv"), ("Web Page (.html)", "html"), ("PDF (.pdf)", "pdf")];
let ext = path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).filter(|e| TYPES.iter().any(|(_, x)| x == e));
let ext = ext.unwrap_or_else(|| "xlsx".to_string());
let mut dialog = rfd::FileDialog::new().set_file_name(format!("{stem}.{ext}"));
for (label, x) in TYPES.iter().filter(|(_, x)| *x == ext).chain(TYPES.iter().filter(|(_, x)| *x != ext)) {
dialog = dialog.add_filter(*label, &[*x]);
}
dialog.save_file().and_then(|p| p.to_str().map(str::to_string))
})),
open_url: Some(Box::new(|url: &str| {
let cmd = if cfg!(target_os = "macos") {
"open"
} else if cfg!(windows) {
"explorer"
} else {
"xdg-open"
};
let _ = std::process::Command::new(cmd).arg(url).spawn();
open_url: Some(Box::new(|url: &str| open_with_system(url))),
// File › Print, Ctrl+P: the print PDF opens in the system's PDF viewer, whose Print
// command reaches the printer dialog (GridCraft has no printer driver of its own).
print: Some(Box::new(|bytes: &[u8]| match write_print_pdf(&std::env::temp_dir(), bytes) {
Ok(path) => open_with_system(&path.to_string_lossy()),
Err(e) => log::warn!("print: {e}"),
})),
..Default::default()
}
}
/// Opens a URL or file with the system's default application.
fn open_with_system(target: &str) {
let cmd = if cfg!(target_os = "macos") {
"open"
} else if cfg!(windows) {
"explorer"
} else {
"xdg-open"
};
if let Err(e) = std::process::Command::new(cmd).arg(target).spawn() {
log::warn!("{cmd} {target}: {e}");
}
}
/// Writes a print PDF into `dir` under a fresh name (an earlier print may still be open in the
/// viewer) and returns its path. The temp folder is shared, so the file is created exclusively
/// (an existing file or a planted symlink is never followed or overwritten) and, on Unix, readable
/// only by the user.
fn write_print_pdf(dir: &std::path::Path, bytes: &[u8]) -> std::io::Result<std::path::PathBuf> {
use std::io::Write;
static PRINTS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
for _ in 0..64 {
let n = PRINTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed).wrapping_add(1);
let path = dir.join(format!("GridCraft print {}-{n}.pdf", std::process::id()));
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
match options.open(&path) {
Ok(mut file) => {
file.write_all(bytes)?;
return Ok(path);
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "no free print file name"))
}
fn icon() -> Option<egui::IconData> {
#[cfg(target_os = "macos")]
let bytes = include_bytes!("../../../assets/app-icon/gridcraft-macos-512.png");
@ -271,6 +313,7 @@ fn main() -> eframe::Result<()> {
session.new_workbook();
}
let mut app = SheetApp::new(session, services());
app.watch_desktop_theme();
app.after_engine(); // Show warnings from files opened on the command line.
let control_port = control_port;
load_prefs(&mut app);
@ -349,3 +392,33 @@ fn main() -> eframe::Result<()> {
}
result
}
#[cfg(test)]
mod print_tests {
#[test]
fn print_pdfs_get_fresh_names_in_the_given_folder() {
let dir = std::env::temp_dir().join(format!("gridcraft-print-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let a = super::write_print_pdf(&dir, b"%PDF-1.4 a").unwrap();
let b = super::write_print_pdf(&dir, b"%PDF-1.4 b").unwrap();
assert_ne!(a, b);
assert!(a.starts_with(&dir) && a.extension().is_some_and(|e| e == "pdf"));
assert_eq!(std::fs::read(&b).unwrap(), b"%PDF-1.4 b");
assert!(super::write_print_pdf(&dir.join("missing"), b"x").is_err());
// A file (or symlink) already at the next name is skipped, never overwritten.
let pid = std::process::id();
let taken: Vec<_> = (1..=200).map(|n| dir.join(format!("GridCraft print {pid}-{n}.pdf"))).filter(|p| !p.exists()).take(3).collect();
for p in &taken {
std::fs::write(p, b"keep").unwrap();
}
let c = super::write_print_pdf(&dir, b"%PDF-1.4 c").unwrap();
assert!(taken.iter().all(|p| std::fs::read(p).unwrap() == b"keep"));
assert_eq!(std::fs::read(&c).unwrap(), b"%PDF-1.4 c");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(std::fs::metadata(&c).unwrap().permissions().mode() & 0o077, 0, "only the user can read it");
}
let _ = std::fs::remove_dir_all(&dir);
}
}

View File

@ -30,7 +30,7 @@ fn tree() -> Vec<(&'static str, Vec<Entry>)> {
("Page Setup…", "dialog:pageSetup", None),
("Print…", "file.print", Some("CMD+P")),
("-", "", None),
("Properties…", "file.properties", None),
("Properties…", "dialog:backstage:info", None),
],
),
(
@ -160,6 +160,8 @@ fn tree() -> Vec<(&'static str, Vec<Entry>)> {
("-", "", None),
("Group", "data.group", None),
("Ungroup", "data.ungroup", None),
("Show Detail", "data.showDetail", None),
("Hide Detail", "data.hideDetail", None),
("Subtotal…", "dialog:subtotal", None),
("-", "", None),
("Calculate Now", "formulas.calculateNow", None),
@ -289,6 +291,7 @@ impl NativeMenu {
("note", _) => json!({"threaded": false}),
("comment", _) => json!({"threaded": true}),
("about", tab) => json!({"tab": tab}),
("backstage", page) => json!({"page": page}),
_ => json!({}),
};
let name = if name == "note" { "comment" } else { name };

View File

@ -51,6 +51,9 @@ sync_github_name = false
[people.Losminek]
sync_github_name = true
[people.GhostMitra]
sync_github_name = true
[people.shrshk]
real_name = "Shirish Veerabattini"
sync_github_name = true

View File

@ -249,6 +249,13 @@ impl<'h> Evaluator<'h> {
let scope = def.scope.unwrap_or(self.sheet);
return match gridcraft_formula::parse(&text) {
Ok(expr) => {
// Relative references in a name are relative to A1: used here, they move by
// this cell's offset from it.
let expr = if gridcraft_formula::adjust::has_relative_refs(&expr) {
gridcraft_formula::adjust::shift_relative_wrapping(expr, self.at.row as i64, self.at.col as i64)
} else {
expr
};
let saved = self.sheet;
// Unqualified references in a name refer to the scope sheet.
self.sheet = scope;
@ -681,6 +688,7 @@ impl<'h> Evaluator<'h> {
"OFFSET" => self.offset(args),
"INDIRECT" => self.indirect(args),
"INDEX" => self.index(args),
"XLOOKUP" => self.xlookup(args),
"CELL" => self.cell_info(args),
"TRIMRANGE" => self.trimrange(args),
"SUBTOTAL" | "AGGREGATE" => self.subtotal(name, args),
@ -1085,18 +1093,57 @@ impl<'h> Evaluator<'h> {
return err(CellError::Value);
}
let src = self.eval(&args[0]);
let row = match self.arg_num(args, 1, 0.0) {
Ok(n) => n.trunc() as i64,
Err(e) => return err(e),
};
let col = match self.arg_num(args, 2, 0.0) {
Ok(n) => n.trunc() as i64,
Err(e) => return err(e),
let mut arg = |i: usize| match args.get(i) {
None | Some(Expr::Missing) => None,
Some(e) => Some(self.value(e)),
};
let (rows, cols) = (arg(1), arg(2));
let area_n = match self.arg_num(args, 3, 1.0) {
Ok(n) => n.trunc() as i64,
Err(e) => return err(e),
};
let two = args.len() == 2;
let num = |v: &Option<Value>| v.as_ref().map_or(Ok(0.0), |v| v.to_number()).map(|n| n.trunc() as i64);
// Arrays of positions pick a value for each: INDEX(A1:C10,{1;2},{1,3}) is 2×2.
if let (Some(Value::Array(_)), _) | (_, Some(Value::Array(_))) = (&rows, &cols) {
let as_array = |v: &Option<Value>| match v {
Some(Value::Array(a)) => Arc::clone(a),
Some(v) => Arc::new(Array::scalar(v.clone())),
None => Arc::new(Array::scalar(Value::Number(0.0))),
};
let (ra, ca) = (as_array(&rows), as_array(&cols));
let (h, w) = (ra.rows.max(ca.rows), ra.cols.max(ca.cols));
if (h as u64).saturating_mul(w as u64) > MAX_CELLS {
return err(CellError::Num);
}
let mut out = Vec::with_capacity(h * w);
for r in 0..h {
for c in 0..w {
let pos = (num(&Some(ra.get_broadcast(r, c))), num(&Some(ca.get_broadcast(r, c))));
let v = match pos {
(Ok(row), Ok(col)) => {
let one = self.index_one(src.clone(), row, col, area_n, two);
match self.deref(one) {
Value::Array(a) if a.rows * a.cols > 1 => Value::Error(CellError::Calc),
v => v.scalar(),
}
}
(Err(e), _) | (_, Err(e)) => Value::Error(e),
};
out.push(v);
}
}
return Ev::V(Array::new(h, w, out).map(Value::from).unwrap_or(Value::Error(CellError::Value)));
}
let (row, col) = match (num(&rows), num(&cols)) {
(Ok(r), Ok(c)) => (r, c),
(Err(e), _) | (_, Err(e)) => return err(e),
};
self.index_one(src, row, col, area_n, two)
}
/// INDEX for one row and column position (0 = the whole column or row).
fn index_one(&mut self, src: Ev, row: i64, col: i64, area_n: i64, two_args: bool) -> Ev {
if row < 0 || col < 0 {
return err(CellError::Value);
}
@ -1105,7 +1152,7 @@ impl<'h> Evaluator<'h> {
let Some(a) = areas.get((area_n - 1).max(0) as usize).copied() else { return err(CellError::Ref) };
let r = a.range;
// A single row or column with one index picks along it.
let (row, col) = if args.len() == 2 && r.height() == 1 { (1, row) } else { (row, col) };
let (row, col) = if two_args && r.height() == 1 { (1, row) } else { (row, col) };
if row as u64 > r.height() as u64 || col as u64 > r.width() as u64 {
return err(CellError::Ref);
}
@ -1114,7 +1161,7 @@ impl<'h> Evaluator<'h> {
Ev::R(vec![Area { sheet: a.sheet, range: RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1)) }])
}
Ev::V(Value::Array(arr)) => {
let (row, col) = if args.len() == 2 && arr.rows == 1 { (1, row) } else { (row, col) };
let (row, col) = if two_args && arr.rows == 1 { (1, row) } else { (row, col) };
if row as usize > arr.rows || col as usize > arr.cols {
return err(CellError::Ref);
}
@ -1137,6 +1184,66 @@ impl<'h> Evaluator<'h> {
}
}
/// XLOOKUP whose result range is a reference returns a reference, as in Excel, so that it can
/// end a range (`SUM(XLOOKUP(a,…):XLOOKUP(b,…))`) or feed CELL. Everything else (arrays of
/// lookup values, array arguments, errors) goes to the library function.
fn xlookup(&mut self, args: &[Expr]) -> Ev {
if (3..=6).contains(&args.len())
&& let (Ev::R(look), Ev::R(ret)) = (self.eval(&args[1]), self.eval(&args[2]))
&& let ([l], [r]) = (look.as_slice(), ret.as_slice())
&& let Some(spec) = gridcraft_functions::lookup("XMATCH")
{
let key = self.value(&args[0]);
let (l, r) = (*l, *r);
let vertical = l.range.width() == 1 && l.range.height() > 1;
let horizontal = l.range.height() == 1 && l.range.width() > 1;
let same_len = if vertical { r.range.height() == l.range.height() } else { r.range.width() == l.range.width() };
if !matches!(key, Value::Array(_)) && (vertical || horizontal) && same_len {
let mut xargs = vec![Arg { value: key, from_ref: false }, Arg { value: self.area_value(&l), from_ref: true }];
// An empty match_mode before a given search_mode is the default (0), not the end.
let given = |i: usize| args.get(i).is_some_and(|e| !matches!(e, Expr::Missing));
let last = if given(5) {
5
} else if given(4) {
4
} else {
3
};
for i in 4..=last {
match args.get(i) {
Some(Expr::Missing) | None => xargs.push(Arg { value: Value::Number(0.0), from_ref: false }),
Some(e) => {
let value = self.value(e);
xargs.push(Arg { value, from_ref: false });
}
}
}
let mut ctx = FnCtx { ev: self };
return match gridcraft_functions::call(spec, &xargs, &mut ctx) {
Value::Number(p) if p >= 1.0 => {
let k = p as u32 - 1;
let range = if vertical {
RangeRef::new(
CellRef::new(r.range.start.row + k, r.range.start.col),
CellRef::new(r.range.start.row + k, r.range.end.col),
)
} else {
RangeRef::new(
CellRef::new(r.range.start.row, r.range.start.col + k),
CellRef::new(r.range.end.row, r.range.start.col + k),
)
};
Ev::R(vec![Area { sheet: r.sheet, range }])
}
Value::Error(CellError::NA) if args.get(3).is_some_and(|e| !matches!(e, Expr::Missing)) => self.eval(&args[3]),
Value::Error(e) => err(e),
_ => err(CellError::Value),
};
}
}
self.builtin("XLOOKUP", args)
}
fn cell_info(&mut self, args: &[Expr]) -> Ev {
let kind = match self.arg_val(args, 0).map(|v| v.scalar().to_text()) {
Some(Ok(t)) => t.to_ascii_lowercase(),

View File

@ -94,6 +94,39 @@ fn chains_out_of_order() {
assert_eq!(t.num("A5"), 5.0);
}
#[test]
fn xlookup_returns_a_reference() {
let mut t = T::new();
for (i, (k, v)) in [("a", 1), ("b", 2), ("c", 3), ("d", 4)].iter().enumerate() {
t.set(&format!("A{}", i + 1), k);
t.set(&format!("B{}", i + 1), &v.to_string());
}
t.set("D1", "=SUM(XLOOKUP(\"b\",A1:A4,B1:B4):XLOOKUP(\"d\",A1:A4,B1:B4))");
assert_eq!(t.num("D1"), 9.0);
t.set("D2", "=SUM(B1:XLOOKUP(\"c\",A1:A4,B1:B4))");
assert_eq!(t.num("D2"), 6.0);
t.set("D3", "=CELL(\"address\",XLOOKUP(\"c\",A1:A4,B1:B4))");
assert_eq!(t.get("D3"), Value::from("$B$3"));
// Not found: if_not_found, else #N/A, as before.
t.set("D4", "=XLOOKUP(\"z\",A1:A4,B1:B4,\"none\")");
assert_eq!(t.get("D4"), Value::from("none"));
t.set("D5", "=XLOOKUP(\"z\",A1:A4,B1:B4)");
assert_eq!(t.get("D5"), Value::Error(CellError::NA));
// A match on an empty cell is a blank reference: ISBLANK is TRUE and & adds nothing (#374).
t.set("A5", "e");
t.set("D6", "=ISBLANK(XLOOKUP(\"e\",A1:A5,B1:B5))");
assert_eq!(t.get("D6"), Value::Bool(true));
t.set("D7", "=\"x\"&XLOOKUP(\"e\",A1:A5,B1:B5)&\"y\"");
assert_eq!(t.get("D7"), Value::from("xy"));
// An empty match_mode keeps the search_mode after it: search last to first.
t.set("A6", "b");
t.set("B6", "20");
t.set("D8", "=XLOOKUP(\"b\",A1:A6,B1:B6,,,-1)");
assert_eq!(t.num("D8"), 20.0);
t.set("D9", "=XLOOKUP(\"b\",A1:A6,B1:B6)");
assert_eq!(t.num("D9"), 2.0);
}
#[test]
fn long_chain() {
let mut t = T::new();
@ -106,6 +139,24 @@ fn long_chain() {
assert_eq!(t.num("A3000"), 3009.0);
}
#[test]
fn index_with_arrays_of_positions() {
let mut t = T::new();
for (i, row) in [[1, 2, 3], [4, 5, 6], [7, 8, 9]].iter().enumerate() {
for (j, v) in row.iter().enumerate() {
t.set(&format!("{}{}", ["A", "B", "C"][j], i + 1), &v.to_string());
}
}
t.set("E1", "=INDEX(A1:C3,{1;3},{1,3})");
assert_eq!([t.num("E1"), t.num("F1"), t.num("E2"), t.num("F2")], [1.0, 3.0, 7.0, 9.0]);
t.set("E4", "=SUM(INDEX(A1:C3,{1,2,3},2))");
assert_eq!(t.num("E4"), 15.0);
t.set("E5", "=INDEX({10,20,30},{3,1})");
assert_eq!([t.num("E5"), t.num("F5")], [30.0, 10.0]);
t.set("E7", "=INDEX(A1:C3,2,3)");
assert_eq!(t.num("E7"), 6.0);
}
#[test]
fn errors_and_cycles() {
let mut t = T::new();
@ -261,6 +312,32 @@ fn references_functions() {
assert_eq!(t.num("B6"), 105.0);
}
#[test]
fn relative_names() {
let mut t = T::new();
// As files store them: relative to A1. `Left` is the cell to the left, `Above3` the three
// cells above, `Mixed` column A of the same row.
for (name, formula) in [("Left", "Sheet1!XFD1"), ("Above3", "Sheet1!A1048574:A1048576"), ("Mixed", "Sheet1!$A1")] {
t.wb.names.push(gridcraft_model::DefinedName {
name: name.into(),
scope: None,
formula: formula.into(),
comment: String::new(),
hidden: false,
});
}
t.calc.rebuild(&t.wb);
for r in 1..=5 {
t.set(&format!("A{r}"), &(r * 10).to_string());
}
t.set("B2", "=Left");
assert_eq!(t.num("B2"), 20.0);
t.set("B4", "=Left+Mixed");
assert_eq!(t.num("B4"), 80.0);
t.set("A6", "=SUM(Above3)");
assert_eq!(t.num("A6"), 120.0);
}
#[test]
fn spills() {
let mut t = T::new();

View File

@ -8,6 +8,7 @@ use gridcraft_model::*;
use serde_json::{Value as Json, json};
use super::*;
use crate::outline;
use crate::selection::current_region;
pub fn specs() -> Vec<CommandSpec> {
@ -74,10 +75,37 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!(query "data.validate", "Check Value Against Validation", [], None, "{cell?, input} → {ok, message?}", has_doc, validate_cmd),
cmd!(noundo "data.circleInvalid", "Circle Invalid Data", ["Data", "Data Tools", "Data Validation"], None, "{} → invalid cells", has_doc, circle_invalid),
cmd!("data.group", "Group", ["Data", "Outline"], Some("Cmd+Shift+K"), "{rows?: \"2:5\" | cols?: \"B:C\"}", has_doc, |s, p| group(s, p, true)),
cmd!("data.ungroup", "Ungroup", ["Data", "Outline"], Some("Cmd+Shift+J"), "{rows? | cols?}", has_doc, |s, p| group(s, p, false)),
cmd!("data.hideDetail", "Hide Detail", ["Data", "Outline"], None, "{rows? | cols?}", has_doc, |s, p| detail(s, p, true)),
cmd!("data.showDetail", "Show Detail", ["Data", "Outline"], None, "{rows? | cols?}", has_doc, |s, p| detail(s, p, false)),
cmd!(
"data.group",
"Group",
["Data", "Outline"],
Some("Alt+Shift+Right"),
"{rows?: \"2:5\" | cols?: \"B:C\" | by?: rows|cols (the selection's rows or columns; a block of cells groups rows by default)}",
has_doc,
|s, p| group(s, p, true)
),
cmd!("data.ungroup", "Ungroup", ["Data", "Outline"], Some("Alt+Shift+Left"), "{rows? | cols? | by?: rows|cols}", has_doc, |s, p| group(
s, p, false
)),
cmd!(
"data.hideDetail",
"Hide Detail",
["Data", "Outline"],
None,
"{rows? | cols? | by?, level?: 1-7} (the innermost group, or the one at `level`, holding the lines or summarised by them)",
has_doc,
|s, p| detail(s, p, true)
),
cmd!("data.showDetail", "Show Detail", ["Data", "Outline"], None, "{rows? | cols? | by?, level?: 1-7}", has_doc, |s, p| detail(s, p, false)),
cmd!(
"data.showLevels",
"Show Outline Levels",
[],
None,
"{rows?: 1-8, cols?: 1-8} (1 shows only the top-level summaries, as the outline's level buttons)",
has_doc,
show_levels
),
cmd!(
"data.subtotal",
"Subtotal",
@ -759,7 +787,7 @@ fn text_to_columns(s: &mut Session, p: &Json) -> Result<Json> {
p.get("fixedWidths").and_then(Json::as_array).map(|a| a.iter().filter_map(|x| x.as_u64().map(|v| v as usize)).collect());
let consecutive = bool_param(p, "treatConsecutive").unwrap_or(false);
let quote = str_param(p, "textQualifier").and_then(|q| q.chars().next()).unwrap_or('"');
let dest = cell_param(p, "destination").unwrap_or(r.start);
let dest = cell_param_on(s, p, "destination", sheet)?.unwrap_or(r.start);
edit(s, |cx| {
let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) };
let mut out: Vec<(CellRef, String)> = Vec::new();
@ -952,10 +980,26 @@ pub fn check_validation(wb: &Workbook, sheet: usize, at: CellRef, input: &str) -
return None;
}
let v = gridcraft_core::parse::parse_input(input, wb.date_system).value;
let num = |f: &str| gridcraft_calc::evaluate(wb, sheet, at, f).to_number().ok();
// The formulas are written for the first cell of the range and see the value being entered,
// as in Excel (a custom `=A1>10` on A1:A9 checks A5's new value at A5).
let first = dv.ranges.first().map(|r| r.start).unwrap_or(at);
let entered;
let wb = if sh.value(at) == v {
wb
} else {
let mut w = wb.clone();
if let Some(sh) = w.sheet_mut(sheet) {
let style = sh.style_id(at);
sh.cells.set(at, Cell { value: v.clone(), style, ..Default::default() });
}
entered = w;
&entered
};
let eval = |f: &str| gridcraft_calc::evaluate(wb, sheet, at, &formula_at(f, first, at));
let num = |f: &str| eval(f).to_number().ok();
let ok = match dv.kind {
ValidationKind::List => list_items(wb, sheet, dv).iter().any(|x| x.eq_ignore_ascii_case(input)),
ValidationKind::Custom => gridcraft_calc::evaluate(wb, sheet, at, &dv.f1).to_bool().unwrap_or(false),
ValidationKind::Custom => eval(&dv.f1).to_bool().unwrap_or(false),
ValidationKind::TextLength => {
let n = input.chars().count() as f64;
compare_op(dv.op, n, num(&dv.f1), dv.f2.as_deref().and_then(num))
@ -982,6 +1026,21 @@ pub fn check_validation(wb: &Workbook, sheet: usize, at: CellRef, input: &str) -
Some((dv.clone(), msg))
}
/// A formula written for `first`, as seen from `at` (relative references shifted).
fn formula_at(f: &str, first: CellRef, at: CellRef) -> String {
if first == at {
return f.to_string();
}
match gridcraft_formula::parse(f.trim_start_matches('=')) {
Ok(e) => gridcraft_formula::print(&gridcraft_formula::adjust::shift_relative(
e,
at.row as i64 - first.row as i64,
at.col as i64 - first.col as i64,
)),
Err(_) => f.to_string(),
}
}
fn compare_op(op: CfOperator, n: f64, a: Option<f64>, b: Option<f64>) -> bool {
let Some(a) = a else { return false };
match op {
@ -998,7 +1057,7 @@ fn compare_op(op: CfOperator, n: f64, a: Option<f64>, b: Option<f64>) -> bool {
fn validate_cmd(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let at = cell_param(p, "cell").unwrap_or(d.selection.active);
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let input = str_param(p, "input").unwrap_or("");
match check_validation(&d.wb, d.wb.active_sheet, at, input) {
None => Ok(json!({"ok": true})),
@ -1036,7 +1095,12 @@ fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> {
return Ok((false, r.start.col, r.end.col));
}
let r = s.doc()?.selection.current();
if r.is_full_cols() && !r.is_full_rows() { Ok((false, r.start.col, r.end.col)) } else { Ok((true, r.start.row, r.end.row)) }
match str_param(p, "by") {
Some("rows") => Ok((true, r.start.row, r.end.row)),
Some("cols" | "columns") => Ok((false, r.start.col, r.end.col)),
_ if r.is_full_cols() && !r.is_full_rows() => Ok((false, r.start.col, r.end.col)),
_ => Ok((true, r.start.row, r.end.row)),
}
}
fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
@ -1045,39 +1109,94 @@ fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
let si = cx.sheet_index();
let sh = cx.sheet_mut(si)?;
let map = if rows { &mut sh.rows } else { &mut sh.cols };
for i in a..=b.min(a + 1_048_575) {
let b = b.min(a.saturating_add(1_048_575));
for i in a..=b {
let e = map.entry(i).or_default();
e.outline = if on { (e.outline + 1).min(7) } else { e.outline.saturating_sub(1) };
e.outline = if on { (e.outline + 1).min(outline::MAX_LEVEL) } else { e.outline.saturating_sub(1) };
if !on && e.outline == 0 {
e.hidden = false;
}
}
// A summary line whose group is gone has nothing left to collapse.
if !on
&& !outline::groups(map).iter().any(|g| g.end == b)
&& let Some(e) = map.get_mut(&b.saturating_add(1))
{
e.collapsed = false;
}
Ok(Json::Null)
})
}
/// Hide Detail / Show Detail: the innermost group (or the one at `level`) that holds the lines, or
/// failing that the one they are the summary of. Hiding marks the summary line collapsed (the
/// + button, saved to XLSX); showing keeps inner groups that were collapsed on their own collapsed.
fn detail(s: &mut Session, p: &Json, hide: bool) -> Result<Json> {
let (rows, a, b) = outline_lines(s, p)?;
let want = p.get("level").and_then(Json::as_u64).map(|l| l.min(outline::MAX_LEVEL as u64) as u8);
edit(s, |cx| {
let si = cx.sheet_index();
let sh = cx.sheet_mut(si)?;
let map = if rows { &mut sh.rows } else { &mut sh.cols };
// Expand to the whole group around the selection.
let level = (a..=b).filter_map(|i| map.get(&i).map(|e| e.outline)).max().unwrap_or(0);
if level == 0 {
return Ok(Json::Null);
let all = outline::groups(map);
let level_ok = |g: &outline::OutlineGroup| want.is_none_or(|l| g.level == l);
let pick = |hit: &dyn Fn(&outline::OutlineGroup) -> bool| -> Vec<outline::OutlineGroup> {
let hits: Vec<_> = all.iter().filter(|g| level_ok(g) && hit(g)).copied().collect();
let deepest = hits.iter().map(|g| g.level).max().unwrap_or(0);
hits.into_iter().filter(|g| g.level == deepest).collect()
};
let mut chosen = pick(&|g| g.start <= b && g.end >= a);
if chosen.is_empty() {
chosen = pick(&|g| g.summary() >= a && g.summary() <= b);
}
let mut lo = a;
while lo > 0 && map.get(&(lo - 1)).is_some_and(|e| e.outline >= level) {
lo -= 1;
for g in chosen {
// Inner groups collapsed on their own (their summary marked), before this one changes.
let inner: Vec<outline::OutlineGroup> = all
.iter()
.filter(|o| o.level > g.level && o.start >= g.start && o.end <= g.end && o.summary() != g.summary())
.filter(|o| map.get(&o.summary()).is_some_and(|e| e.collapsed))
.copied()
.collect();
for i in g.start..=g.end {
if let Some(e) = map.get_mut(&i) {
e.hidden = hide || inner.iter().any(|o| o.contains(i));
}
}
if hide {
map.entry(g.summary()).or_default().collapsed = true;
} else if let Some(e) = map.get_mut(&g.summary()) {
e.collapsed = false;
}
}
let mut hi = b;
while map.get(&(hi + 1)).is_some_and(|e| e.outline >= level) {
hi += 1;
}
for i in lo..=hi {
if let Some(e) = map.get_mut(&i) {
e.hidden = hide;
cx.structural = true;
Ok(Json::Null)
})
}
/// The outline's level buttons: show rows / columns down to outline level `n` (1 = only the
/// top-level summaries); deeper detail is hidden and its summary lines marked collapsed.
fn show_levels(s: &mut Session, p: &Json) -> Result<Json> {
let level = |k: &str| p.get(k).and_then(Json::as_u64).map(|n| n.clamp(1, outline::MAX_LEVEL as u64 + 1) as u8);
let (rows, cols) = (level("rows"), level("cols"));
if rows.is_none() && cols.is_none() {
return Err(EngineError::BadParams {
cmd: "data.showLevels".into(),
msg: "give `rows` or `cols`: the outline level to show (1 = top-level summaries only)".into(),
});
}
edit(s, |cx| {
let si = cx.sheet_index();
let sh = cx.sheet_mut(si)?;
for (map, n) in [(&mut sh.rows, rows), (&mut sh.cols, cols)] {
let Some(n) = n else { continue };
let groups = outline::groups(map);
for e in map.values_mut().filter(|e| e.outline > 0) {
e.hidden = e.outline >= n;
}
// Inner groups come first, so a summary line shared with the group around it ends up
// as that outer group says.
for g in groups {
map.entry(g.summary()).or_default().collapsed = g.level >= n;
}
}
cx.structural = true;
@ -1176,6 +1295,81 @@ mod tests {
assert!(!ok(&mut s, "B1", "150"));
}
/// Rows 5:8 grouped, with 6:7 grouped again inside (0-based 4..=7 and 5..=6).
fn nested_rows() -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("data.group", json!({"rows": "5:8"})).unwrap();
s.execute("data.group", json!({"rows": "6:7"})).unwrap();
s
}
/// (hidden, collapsed) of 0-based rows.
fn row_state(s: &Session, rows: std::ops::RangeInclusive<u32>) -> Vec<(bool, bool)> {
let sh = s.doc().unwrap().wb.active().unwrap();
rows.map(|r| sh.rows.get(&r).map(|l| (l.hidden, l.collapsed)).unwrap_or_default()).collect()
}
#[test]
fn hide_detail_marks_the_summary_collapsed_and_show_detail_keeps_inner_groups_collapsed() {
let mut s = nested_rows();
s.execute("data.hideDetail", json!({"rows": "6:7", "level": 2})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(false, false), (true, false), (true, false), (false, true), (false, false)]);
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(true, false), (true, false), (true, false), (true, true), (false, true)]);
s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(false, false), (true, false), (true, false), (false, true), (false, false)]);
s.execute("data.showDetail", json!({"rows": "6:7", "level": 2})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(false, false); 5]);
// Each step is one undoable command.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(row_state(&s, 5..=7), [(true, false), (true, false), (false, true)]);
}
#[test]
fn hide_detail_on_the_summary_row_collapses_the_group_above_it() {
let mut s = nested_rows();
s.execute("data.hideDetail", json!({"rows": "9:9"})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(true, false), (true, false), (true, false), (true, false), (false, true)]);
}
#[test]
fn show_levels_collapses_and_expands_to_a_level() {
let mut s = nested_rows();
s.execute("data.showLevels", json!({"rows": 1})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(true, false), (true, false), (true, false), (true, true), (false, true)]);
s.execute("data.showLevels", json!({"rows": 2})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(false, false), (true, false), (true, false), (false, true), (false, false)]);
s.execute("data.showLevels", json!({"rows": 3})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(false, false); 5]);
// Columns are separate; no level at all is an error rather than a guess.
s.execute("data.group", json!({"cols": "C:E"})).unwrap();
s.execute("data.showLevels", json!({"cols": 1})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert!((2..=4).all(|c| sh.cols.get(&c).is_some_and(|l| l.hidden)));
assert!(sh.cols.get(&5).is_some_and(|l| l.collapsed));
assert_eq!(row_state(&s, 4..=4), [(false, false)]);
assert!(s.execute("data.showLevels", json!({})).is_err());
}
#[test]
fn group_by_columns_for_a_block_of_cells() {
let mut s = Session::new();
s.new_workbook();
s.execute("selection.set", json!({"range": "B2:C5"})).unwrap();
s.execute("data.group", json!({"by": "cols"})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert!((1..=2).all(|c| sh.cols.get(&c).is_some_and(|l| l.outline == 1)));
assert!(sh.rows.values().all(|l| l.outline == 0));
}
#[test]
fn group_shortcuts_are_the_windows_ones() {
let sc = |id: &str| specs().into_iter().find(|c| c.id == id).and_then(|c| c.shortcut);
assert_eq!(sc("data.group"), Some("Alt+Shift+Right"));
assert_eq!(sc("data.ungroup"), Some("Alt+Shift+Left"));
}
#[test]
fn split() {
assert_eq!(split_delimited("a,b,,c", &[','], false, '"'), ["a", "b", "", "c"]);

View File

@ -94,6 +94,7 @@ fn stroke(s: &mut Session, p: &Json) -> Result<Json> {
fill: Color::from_hex(&format!("#{:02X}0000", width.round().clamp(0.0, 255.0) as u8)).unwrap_or_default(),
line: Color::from_hex(&color).unwrap_or(Color::rgb(0x1F, 0x5F, 0xC9)),
text,
format: Default::default(),
};
edit(s, |cx| {
cx.sheet_mut(si)?.shapes.push(shape);

View File

@ -214,7 +214,7 @@ fn check_arg_counts(expr: &gridcraft_formula::Expr) -> Result<()> {
fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = match cell_param(p, "cell") {
let at = match cell_param_on(s, p, "cell", sheet)? {
Some(c) => c,
None => s.doc()?.selection.active,
};
@ -368,8 +368,9 @@ fn selection_set(s: &mut Session, p: &Json) -> Result<Json> {
s.doc_mut()?.wb_switch_sheet(i);
}
let ranges = target_ranges(s, p)?;
let active = cell_param(s, p, "active")?;
let d = s.doc_mut()?;
let active = cell_param(p, "active").or_else(|| ranges.last().map(|r| r.start)).unwrap_or_default();
let active = active.or_else(|| ranges.last().map(|r| r.start)).unwrap_or_default();
let mut sel = crate::Selection { active, anchor: ranges.last().map(|r| r.start).unwrap_or(active), ranges };
if let Some(sh) = d.wb.active() {
sel.expand_merges(sh);
@ -788,7 +789,7 @@ fn paste(s: &mut Session, p: &Json) -> Result<Json> {
fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param(p, "at").unwrap_or(s.doc()?.selection.current().start);
let at = cell_param_on(s, p, "at", sheet)?.unwrap_or(s.doc()?.selection.current().start);
let rows: Vec<Vec<String>> = parse_tsv(text);
// The HTML flavour, when the host passed it: the number behind each cell's shown text.
let html = str_param(p, "html").map(crate::clip_html::table_cells).unwrap_or_default();
@ -916,14 +917,14 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
}
let dest_sheet = target_sheet(s, p)?;
let sel = s.doc()?.selection.current();
let at = cell_param(p, "at").unwrap_or(sel.start);
let at = cell_param_on(s, p, "at", dest_sheet)?.unwrap_or(sel.start);
let src = clip.range;
let (h, w) = if transpose { (src.width(), src.height()) } else { (src.height(), src.width()) };
// Tile the copy over a larger destination that is a multiple of its size.
let tile = sel.height() % h == 0
&& sel.width() % w == 0
&& !sel.is_single()
&& cell_param(p, "at").is_none()
&& cell_param_on(s, p, "at", dest_sheet)?.is_none()
&& !sel.is_full_cols()
&& !sel.is_full_rows()
&& sel.count() <= 4_000_000;

View File

@ -444,7 +444,7 @@ fn move_chart(s: &mut Session, p: &Json) -> Result<Json> {
Some(n) => Some(s.doc()?.wb.sheet_index(n).ok_or_else(|| bad("chart.move", "no such sheet"))?),
None => None,
};
let at = cell_param(p, "at");
let at = cell_param_on(s, p, "at", target.unwrap_or(si))?;
edit(s, |cx| {
let mut chart = cx.sheet_mut(si)?.charts.remove(ci);
if let Some(a) = at {
@ -876,10 +876,16 @@ fn new_script(s: &mut Session, p: &Json) -> Result<Json> {
}
fn all_scripts(s: &mut Session, _: &Json) -> Result<Json> {
let all = stash_get(&s.doc()?.wb, SCRIPTS_NAME);
Ok(Json::Array(
all.as_object().map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len)})).collect()).unwrap_or_default(),
))
let wb = &s.doc()?.wb;
let all = stash_get(wb, SCRIPTS_NAME);
let mut list: Vec<Json> =
all.as_object().map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len)})).collect()).unwrap_or_default();
// A preserved VBA project is reported alongside GridCraft's own scripts; it is carried on
// save but never executed here, so it has no step count.
if let Some(bytes) = &wb.vba_project {
list.push(json!({"name": "(VBA)", "steps": 0, "kind": "vba", "size": bytes.len(), "executed": false}));
}
Ok(Json::Array(list))
}
fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
@ -951,7 +957,7 @@ fn checkbox(s: &mut Session, p: &Json) -> Result<Json> {
fn toggle_checkbox(s: &mut Session, p: &Json) -> Result<Json> {
let si = target_sheet(s, p)?;
let d = s.doc()?;
let cells: Vec<CellRef> = match cell_param(p, "cell") {
let cells: Vec<CellRef> = match cell_param_on(s, p, "cell", si)? {
Some(c) => vec![c],
None => d.selection.ranges.iter().flat_map(|r| r.iter().take(100_000).collect::<Vec<_>>()).collect(),
};
@ -1023,7 +1029,7 @@ fn new_cell_style(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").ok_or_else(|| bad("home.newCellStyle", "missing `name`"))?.to_string();
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let c = cell_param(p, "fromCell").unwrap_or(d.selection.active);
let c = cell_param(s, p, "fromCell")?.unwrap_or(d.selection.active);
let style = d.wb.styles.get(sh.style_id(c)).clone();
edit(s, |cx| {
cx.wb.cell_styles.retain(|(n, _)| !n.eq_ignore_ascii_case(&name));

View File

@ -8,9 +8,9 @@ use crate::DocState;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(noundo "file.new", "New Workbook", ["File"], Some("Cmd+N"), "{sample?: \"budget\"|\"sales\"|\"grades\"}", always, new_workbook),
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64} (xlsx, xlsm, xlsb and ods data import, csv, tsv, txt, json)", always, open),
cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} (xlsx by default; .csv/.tsv/.json/.html by extension)", has_doc, save),
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path}", has_doc, save_as),
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64} (xlsx, xlsm, xls import, xlsb and ods data import, csv, tsv, txt, json)", always, open),
cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} (xlsx by default; .csv/.tsv/.json/.html/.pdf by extension)", has_doc, save),
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path} (.xlsx, .csv, .tsv, .json, .html, or .pdf: a PDF of the active sheet)", has_doc, save_as),
cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes),
cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{force?: bool}", has_doc, close),
cmd!(noundo "file.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?}", has_doc, |s, p| export(s, p, "csv")),
@ -56,8 +56,9 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
return ok();
};
let (wb, warnings) = crate::io::open_bytes(&name, &bytes)?;
let imported = matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb))
|| matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb);
let imported =
matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb | crate::io::FileKind::Xls))
|| matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb | gridcraft_xlsx::Format::Xls);
let title = if imported {
format!("{} (imported).xlsx", std::path::Path::new(&name).file_stem().and_then(|n| n.to_str()).unwrap_or("Book"))
} else {
@ -93,6 +94,11 @@ fn save(s: &mut Session, p: &Json) -> Result<Json> {
return ok();
}
};
// A PDF is a print of the sheet (Page Setup applies), not a copy of the workbook: the
// document keeps its file and its unsaved changes.
if std::path::Path::new(&path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) {
return super::print::export_pdf(s, &json!({"path": path}));
}
let d = s.doc_mut()?;
let bytes = crate::io::save_bytes(&d.wb, &path)?;
crate::io::write_file(&path, &bytes)?;

View File

@ -0,0 +1,546 @@
//! Data › Forecast › Forecast Sheet: a new sheet with the history, a FORECAST.ETS column, the
//! FORECAST.ETS.CONFINT confidence bounds, optional FORECAST.ETS.STAT statistics and a chart.
//!
//! Like Excel, the sheet holds a copy of the timeline and values (sorted by time) and live
//! formulas over that copy, so the forecast follows edits to the copied history.
use std::sync::Arc;
use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_functions::Timeline;
use gridcraft_model::{Anchor, Cell, Chart, ChartKind, Formula, LegendPos, Series, Sheet, StyleId, Table, TableColumn, TotalsFn};
use serde_json::{Value as Json, json};
use super::*;
use crate::selection::current_region;
const CMD: &str = "data.forecastSheet";
/// Most history rows a forecast sheet copies.
const MAX_HISTORY: u64 = 10_000;
/// Most forecast rows.
const MAX_PERIODS: u32 = 10_000;
/// Most history rows × forecast rows. Every forecast row holds three ETS formulas that each refit
/// the model over the whole history (about 3 µs per history point in a release build), so this
/// bounds a sheet's recalculation to a few seconds: 10,000 rows of history get 50 forecast rows,
/// 50 rows of history the full 10,000.
const MAX_WORK: u64 = 500_000;
/// Forecast rows by default: a third of the history, within [`max_periods`].
fn default_periods(history: usize) -> u32 {
u32::try_from(history.div_ceil(3)).unwrap_or(u32::MAX).clamp(1, max_periods(history))
}
/// Most forecast rows for `history` rows of history.
fn max_periods(history: usize) -> u32 {
let h = u64::try_from(history).unwrap_or(u64::MAX).max(1);
u32::try_from(MAX_WORK / h).unwrap_or(u32::MAX).clamp(1, MAX_PERIODS)
}
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"data.forecastSheet",
"Forecast Sheet",
["Data", "Forecast"],
None,
"{timeline?: \"A2:A25\", values?: \"B2:B25\" (default: the first two columns of `range`, the selection or the current region; a text first cell is the header), end?: timeline value or date text | periods?: n (default: a third of the history, at least 1; ≤10,000 and history × periods ≤ 500,000; history ≤ 10,000 rows), confidenceInterval?: bool (default true), confidence?: 0.95 or 95, seasonality?: \"auto\" | 0 (none) | 2–8,760, fillMissing?: interpolate|zeros, aggregate?: average|count|counta|max|median|min|sum, statistics?: bool, chart?: line|column} → {sheet, table, chart, history, periods}. Inserts a sheet before the active one: timeline, values, Forecast (FORECAST.ETS), Lower/Upper Confidence Bound (FORECAST.ETS.CONFINT), optional FORECAST.ETS.STAT table, and a chart.",
has_doc,
forecast_sheet
)]
}
/// The cells of a one-row or one-column range, at most `MAX_HISTORY + 1`.
fn line_cells(r: RangeRef) -> Result<Vec<CellRef>> {
if r.count() > MAX_HISTORY + 1 {
return Err(bad(CMD, format!("at most {MAX_HISTORY} rows of history")));
}
if r.width() == 1 {
Ok((r.start.row..=r.end.row).map(|row| CellRef::new(row, r.start.col)).collect())
} else if r.height() == 1 {
Ok((r.start.col..=r.end.col).map(|col| CellRef::new(r.start.row, col)).collect())
} else {
Err(bad(CMD, "the timeline and the values are each one row or one column"))
}
}
/// A range parameter on any sheet (whole columns trimmed to the used range).
fn range_on(s: &Session, t: &str) -> Result<(usize, RangeRef)> {
let d = s.doc()?;
let (sh, body) = split_sheet(t);
let si = match sh {
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad(CMD, format!("no sheet named `{n}`")))?,
None => d.wb.active_sheet,
};
let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(CMD, format!("not a range: `{t}`")))?;
let r = if r.is_full_cols() || r.is_full_rows() {
match d.wb.sheet(si).and_then(|s| s.used_range()) {
Some(u) => r.intersection(&RangeRef::new(CellRef::new(0, 0), u.end)).unwrap_or(RangeRef::cell(r.start)),
None => RangeRef::cell(r.start),
}
} else {
r
};
Ok((si, r))
}
/// (sheet, timeline cells, value cells).
fn source(s: &Session, p: &Json) -> Result<(usize, Vec<CellRef>, Vec<CellRef>)> {
let text = |k: &str| str_param(p, k).map(str::trim).filter(|t| !t.is_empty());
match (text("timeline"), text("values")) {
(Some(t), Some(v)) => {
let (ts, tr) = range_on(s, t)?;
let (vs, vr) = range_on(s, v)?;
if ts != vs {
return Err(bad(CMD, "the timeline and the values must be on one sheet"));
}
Ok((ts, line_cells(tr)?, line_cells(vr)?))
}
(None, None) => {
let d = s.doc()?;
let (si, r) = match text("range") {
Some(t) => range_on(s, t)?,
None => {
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let sel = d.selection.current();
(d.wb.active_sheet, if sel.is_single() { current_region(sh, d.selection.active) } else { sel })
}
};
if r.width() < 2 {
return Err(EngineError::Other("Select a timeline column and a values column for the forecast.".into()));
}
let col = |c: u32| RangeRef::new(CellRef::new(r.start.row, c), CellRef::new(r.end.row, c));
Ok((si, line_cells(col(r.start.col))?, line_cells(col(r.start.col + 1))?))
}
_ => Err(bad(CMD, "give both `timeline` and `values`, or neither")),
}
}
/// A number parameter given as a number or as text.
fn number(p: &Json, key: &str) -> Result<Option<f64>> {
match p.get(key) {
None | Some(Json::Null) => Ok(None),
Some(Json::Number(n)) => Ok(n.as_f64()),
Some(Json::String(t)) if t.trim().is_empty() => Ok(None),
Some(Json::String(t)) => {
t.trim().trim_end_matches('%').trim().parse::<f64>().map(Some).map_err(|_| bad(CMD, format!("`{key}` is not a number")))
}
Some(_) => Err(bad(CMD, format!("`{key}` is not a number"))),
}
}
struct Options {
seasonality: u32,
fill: u8,
aggregate: Option<u8>,
confidence: Option<f64>,
statistics: bool,
column_chart: bool,
}
fn options(p: &Json) -> Result<Options> {
let seasonality = match p.get("seasonality") {
Some(Json::String(t)) if t.trim().is_empty() || t.trim().eq_ignore_ascii_case("auto") => 1,
None | Some(Json::Null) => 1,
_ => match number(p, "seasonality")? {
Some(n) if n == n.trunc() && (0.0..=8760.0).contains(&n) => n as u32,
_ => return Err(bad(CMD, "`seasonality` is \"auto\", 0 (none) or a season length from 2 to 8,760")),
},
};
let fill = match str_param(p, "fillMissing").map(|t| t.trim().to_ascii_lowercase()) {
None => 1,
Some(t) if t.is_empty() || t == "interpolate" || t == "interpolation" => 1,
Some(t) if t == "zeros" || t == "zero" => 0,
Some(t) => return Err(bad(CMD, format!("`fillMissing` is interpolate or zeros, not `{t}`"))),
};
let aggregate = match str_param(p, "aggregate").map(|t| t.trim().to_ascii_lowercase()) {
None => None,
Some(t) => match t.as_str() {
"" | "average" => None,
"count" => Some(2),
"counta" => Some(3),
"max" => Some(4),
"median" => Some(5),
"min" => Some(6),
"sum" => Some(7),
_ => return Err(bad(CMD, format!("unknown `aggregate`: `{t}`"))),
},
};
let confidence = if bool_param(p, "confidenceInterval").unwrap_or(true) {
let c = number(p, "confidence")?.unwrap_or(0.95);
let c = if c >= 1.0 { c / 100.0 } else { c };
if !(c > 0.0 && c < 1.0) {
return Err(bad(CMD, "`confidence` is between 0 and 1 (or 0 and 100%)"));
}
Some(c)
} else {
None
};
let column_chart = match str_param(p, "chart").map(|t| t.trim().to_ascii_lowercase()) {
None => false,
Some(t) if t.is_empty() || t == "line" => false,
Some(t) if t == "column" => true,
Some(t) => return Err(bad(CMD, format!("`chart` is line or column, not `{t}`"))),
};
Ok(Options { seasonality, fill, aggregate, confidence, statistics: bool_param(p, "statistics").unwrap_or(false), column_chart })
}
fn col_letters(c: u32) -> String {
gridcraft_core::col_to_letters(c)
}
fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
let opts = options(p)?;
let (si, tcells, vcells) = source(s, p)?;
if tcells.len() != vcells.len() {
return Err(bad(CMD, "the timeline and the values need the same number of cells"));
}
let d = s.doc()?;
if d.wb.protected_structure {
return Err(EngineError::Other("The workbook is protected and cannot be changed.".into()));
}
let sys = d.wb.date_system;
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
// A text first cell is the header.
let header = tcells.first().is_some_and(|c| sh.value(*c).is_text()) || vcells.first().is_some_and(|c| sh.value(*c).is_text());
let name_of = |c: Option<&CellRef>, fallback: &str| match c.map(|c| sh.value(*c)) {
Some(v) if header && !v.is_empty() => v.display(),
_ => fallback.to_string(),
};
let (tname, vname) = (name_of(tcells.first(), "Timeline"), name_of(vcells.first(), "Values"));
let skip = usize::from(header);
let mut rows: Vec<(f64, Value)> = Vec::new();
for (t, v) in tcells.iter().zip(&vcells).skip(skip) {
let (tv, vv) = (sh.value(*t), sh.value(*v));
match tv {
Value::Number(x) => rows.push((x, if matches!(vv, Value::Number(_)) { vv } else { Value::Empty })),
Value::Empty if vv.is_empty() => {}
_ => return Err(EngineError::Other(format!("The timeline must hold numbers or dates ({} isn't one).", t.a1()))),
}
}
rows.sort_by(|a, b| a.0.total_cmp(&b.0));
let times: Vec<f64> = rows.iter().map(|r| r.0).collect();
let timeline =
Timeline::detect(&times, sys).ok_or_else(|| EngineError::Other("The timeline needs at least two points a constant step apart.".into()))?;
if !rows.iter().any(|r| matches!(r.1, Value::Number(_))) {
return Err(EngineError::Other("There are no values to forecast.".into()));
}
let end = match p.get("end") {
Some(Json::Number(n)) => n.as_f64(),
Some(Json::String(t)) if !t.trim().is_empty() => match gridcraft_core::parse::parse_input(t.trim(), sys).value {
Value::Number(end) => Some(end),
_ => return Err(bad(CMD, format!("`end` is not a number or a date: `{t}`"))),
},
_ => None,
};
let cap = max_periods(rows.len());
let periods = match end {
Some(end) => steps_to(&timeline, end, cap)?,
None => match number(p, "periods")? {
Some(n) if n >= 1.0 && n.ceil() <= f64::from(cap) => n.ceil() as u32,
Some(_) => return Err(bad(CMD, format!("`periods` is from 1 to {cap} for {} rows of history", rows.len()))),
None => default_periods(rows.len()),
},
};
let future: Vec<f64> =
(1..=periods).map(|k| timeline.after_last(k)).collect::<Option<_>>().ok_or_else(|| bad(CMD, "the forecast runs past the last date"))?;
let fmt_of = |c: Option<&CellRef>| c.map(|c| d.wb.styles.get(sh.style_id(*c)).num_fmt.clone()).unwrap_or_default();
let (tfmt, vfmt) = (fmt_of(tcells.get(skip)), fmt_of(vcells.get(skip)));
let sheet_name = d.wb.next_sheet_name();
let table_name = (1..).map(|n| format!("Table{n}")).find(|n| d.wb.table(n).is_none()).unwrap_or_else(|| "Table".into());
let chart_id = d.wb.next_object_id();
let table_id = chart_id.saturating_add(1);
// Columns: A timeline, B values, C forecast, D/E bounds.
let n = rows.len() as u32;
let last = n + 1; // 1-based row of the last history point
let end_row = last + periods; // 1-based row of the last forecast
let hist = |col: u32| format!("${0}$2:${0}${last}", col_letters(col));
let mut tail = format!("{},{}", opts.seasonality, opts.fill);
if let Some(a) = opts.aggregate {
tail.push_str(&format!(",{a}"));
}
let mut headers = vec![tname, vname.clone(), format!("Forecast({vname})")];
if opts.confidence.is_some() {
headers.push(format!("Lower Confidence Bound({vname})"));
headers.push(format!("Upper Confidence Bound({vname})"));
}
// Table column names are unique.
for i in 1..headers.len() {
let base = headers.get(i).cloned().unwrap_or_default();
let mut name = base.clone();
let mut k = 2;
while headers.iter().take(i).any(|h| h.eq_ignore_ascii_case(&name)) {
name = format!("{base}{k}");
k += 1;
}
if let Some(h) = headers.get_mut(i) {
*h = name;
}
}
let width = headers.len() as u32;
let stats_col = width + 1;
let chart_col = if opts.statistics { stats_col + 3 } else { width + 1 };
let at = s.doc()?.wb.active_sheet;
let index = edit(s, |cx| {
let at = at.min(cx.wb.sheets.len());
let bold = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.font.bold = true);
let tstyle = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.num_fmt = tfmt.clone());
let vstyle = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.num_fmt = vfmt.clone());
let mut sheet = Sheet::new(sheet_name.clone());
let put = |sheet: &mut Sheet, row: u32, col: u32, v: Value, style: StyleId| {
sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style });
};
let formula = |sheet: &mut Sheet, row: u32, col: u32, text: String, style: StyleId| {
let mut cell = Cell::formula(Formula::new(&text));
cell.style = style;
sheet.set_cell(CellRef::new(row, col), cell);
};
for (c, h) in headers.iter().enumerate() {
put(&mut sheet, 0, c as u32, Value::text(h.as_str()), StyleId::DEFAULT);
}
for (i, (t, v)) in rows.iter().enumerate() {
let r = i as u32 + 1;
put(&mut sheet, r, 0, Value::Number(*t), tstyle);
if !v.is_empty() {
put(&mut sheet, r, 1, v.clone(), vstyle);
}
}
// The last history point starts the forecast lines, so they join the values.
formula(&mut sheet, n, 2, format!("=B{last}"), vstyle);
if opts.confidence.is_some() {
formula(&mut sheet, n, 3, format!("=C{last}"), vstyle);
formula(&mut sheet, n, 4, format!("=C{last}"), vstyle);
}
let (tl, vl) = (hist(0), hist(1));
for (k, t) in future.iter().enumerate() {
let r = last + k as u32 + 1; // 1-based
put(&mut sheet, r - 1, 0, Value::Number(*t), tstyle);
formula(&mut sheet, r - 1, 2, format!("=FORECAST.ETS(A{r},{vl},{tl},{tail})"), vstyle);
if let Some(c) = opts.confidence {
let ci = format!("FORECAST.ETS.CONFINT(A{r},{vl},{tl},{c},{tail})");
formula(&mut sheet, r - 1, 3, format!("=C{r}-{ci}"), vstyle);
formula(&mut sheet, r - 1, 4, format!("=C{r}+{ci}"), vstyle);
}
}
if opts.statistics {
put(&mut sheet, 0, stats_col, Value::text("Statistic"), bold);
put(&mut sheet, 0, stats_col + 1, Value::text("Value"), bold);
for (k, label) in ["Alpha", "Beta", "Gamma", "MASE", "SMAPE", "MAE", "RMSE"].iter().enumerate() {
let r = k as u32 + 1;
put(&mut sheet, r, stats_col, Value::text(*label), StyleId::DEFAULT);
formula(&mut sheet, r, stats_col + 1, format!("=FORECAST.ETS.STAT({vl},{tl},{},{tail})", k + 1), StyleId::DEFAULT);
}
}
for c in 0..width {
sheet.cols.insert(
c,
gridcraft_model::LineInfo {
size: Some(match c {
0 | 1 => 90.0,
2 => 130.0,
_ => 230.0,
}),
..Default::default()
},
);
}
let range = RangeRef::new(CellRef::new(0, 0), CellRef::new(end_row - 1, width - 1));
sheet.tables.push(Table {
id: table_id,
name: table_name.clone(),
range,
header_row: true,
totals_row: false,
columns: headers.iter().map(|h| TableColumn { name: h.clone(), totals: TotalsFn::None, totals_label: None, formula: None }).collect(),
style: "TableStyleMedium2".into(),
banded_rows: true,
banded_cols: false,
first_col: false,
last_col: false,
filter_button: true,
});
let q = gridcraft_formula::quote_sheet(&sheet_name);
let col_ref = |c: u32| format!("{q}!${0}$2:${0}${end_row}", col_letters(c));
let series = (1..width)
.map(|c| Series {
name: Some(format!("{q}!${}$1", col_letters(c))),
categories: Some(col_ref(0)),
values: col_ref(c),
bubble_sizes: None,
color: None,
secondary: false,
kind: (opts.column_chart && c >= 3).then_some(ChartKind::Line),
smooth: false,
})
.collect();
sheet.charts.push(Chart {
id: chart_id,
kind: if opts.column_chart { ChartKind::ColumnClustered } else { ChartKind::Line },
anchor: Anchor { cell: CellRef::new(1, chart_col), dx: 0.0, dy: 0.0, width: 480.0, height: 288.0, mode: Default::default() },
title: None,
series,
legend: LegendPos::Bottom,
data_labels: false,
gridlines: true,
style: 1,
x_title: None,
y_title: None,
source: None,
by_rows: false,
pivot: None,
});
cx.wb.sheets.insert(at, Arc::new(sheet));
for nm in cx.wb.names.iter_mut() {
if let Some(sc) = nm.scope.as_mut()
&& *sc >= at
{
*sc += 1;
}
}
cx.wb.active_sheet = at;
*cx.sel = crate::Selection::at(CellRef::new(0, 0));
cx.structural = true;
Ok(at)
})?;
if let Ok(d) = s.doc_mut() {
d.sheet_selections.insert(index.min(d.sheet_selections.len()), crate::Selection::default());
}
Ok(json!({"sheet": sheet_name, "table": table_name, "chart": chart_id, "history": n, "periods": periods}))
}
/// Forecast rows needed to reach `end` (rounded up to whole steps).
fn steps_to(timeline: &Timeline, end: f64, cap: u32) -> Result<u32> {
let steps = timeline.steps_after_last(end).ok_or_else(|| bad(CMD, "`end` is not on the timeline"))?;
if steps < 1e-9 {
return Err(EngineError::Other("The forecast end must be after the last point of the timeline.".into()));
}
let steps = steps.ceil();
if steps > f64::from(cap) {
return Err(bad(CMD, format!("at most {cap} forecast steps for this much history")));
}
Ok(steps as u32)
}
#[cfg(test)]
mod tests {
use gridcraft_core::{CellRef, Value};
use serde_json::json;
use crate::Session;
fn session_with_history() -> Session {
let mut s = Session::new();
s.new_workbook();
let mut rows = vec![json!(["Month", "Sales"])];
// y = 10 + 2t with a four-step season.
let season = [3.0, -1.0, 4.0, -6.0];
for t in 1..=16 {
rows.push(json!([t, 10.0 + 2.0 * t as f64 + season[(t - 1) % 4]]));
}
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
s
}
fn value(s: &Session, sheet: usize, a1: &str) -> Value {
s.doc().unwrap().wb.sheet(sheet).unwrap().value(CellRef::parse(a1).unwrap())
}
fn formula(s: &Session, sheet: usize, a1: &str) -> String {
let wb = &s.doc().unwrap().wb;
wb.sheet(sheet).unwrap().cell(CellRef::parse(a1).unwrap()).and_then(|c| c.formula.as_ref()).map(|f| f.text.clone()).unwrap_or_default()
}
#[test]
fn forecast_sheet_builds_table_formulas_and_chart() {
let mut s = session_with_history();
s.execute("selection.set", json!({"range": "A1:B17"})).unwrap();
let r = s.execute("data.forecastSheet", json!({"periods": 4, "statistics": true})).unwrap();
assert_eq!(r["history"], 16);
assert_eq!(r["periods"], 4);
let wb = &s.doc().unwrap().wb;
// Inserted before the data sheet and activated.
assert_eq!(wb.active_sheet, 0);
let sh = wb.sheet(0).unwrap();
assert_eq!(sh.name, r["sheet"].as_str().unwrap());
assert_eq!(value(&s, 0, "C1"), Value::text("Forecast(Sales)"));
assert_eq!(value(&s, 0, "D1"), Value::text("Lower Confidence Bound(Sales)"));
assert_eq!(value(&s, 0, "E1"), Value::text("Upper Confidence Bound(Sales)"));
assert_eq!(formula(&s, 0, "C17"), "B17");
assert_eq!(formula(&s, 0, "C18"), "FORECAST.ETS(A18,$B$2:$B$17,$A$2:$A$17,1,1)");
assert_eq!(formula(&s, 0, "D18"), "C18-FORECAST.ETS.CONFINT(A18,$B$2:$B$17,$A$2:$A$17,0.95,1,1)");
assert_eq!(formula(&s, 0, "H2"), "FORECAST.ETS.STAT($B$2:$B$17,$A$2:$A$17,1,1,1)");
// Future timeline and the exact continuation of the seasonal series (t = 17 → 10 + 34 + 3).
assert_eq!(value(&s, 0, "A18"), Value::Number(17.0));
assert_eq!(value(&s, 0, "A21"), Value::Number(20.0));
let c18 = value(&s, 0, "C18").as_f64().unwrap();
assert!((c18 - 47.0).abs() < 1e-6, "{c18}");
let (lo, hi) = (value(&s, 0, "D19").as_f64().unwrap(), value(&s, 0, "E19").as_f64().unwrap());
assert!(lo <= hi);
assert_eq!(sh.tables.first().map(|t| t.range.a1()), Some("A1:E21".into()));
let chart = sh.charts.first().unwrap();
assert_eq!(chart.series.len(), 4);
assert_eq!(chart.series[1].values, format!("{}!$C$2:$C$21", r["sheet"].as_str().unwrap()));
// One undo step removes the sheet again.
let sheets = s.doc().unwrap().wb.sheets.len();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.doc().unwrap().wb.sheets.len(), sheets - 1);
}
#[test]
fn history_and_periods_are_capped() {
use super::{MAX_HISTORY, MAX_PERIODS, MAX_WORK, default_periods, max_periods};
// The default (a third of the history) never exceeds the caps.
assert_eq!(default_periods(16), 6);
assert_eq!(default_periods(1_200), 400);
assert_eq!(default_periods(3_000), 166);
assert_eq!(default_periods(100_000), 5);
assert_eq!(max_periods(10), MAX_PERIODS);
assert_eq!(max_periods(0), MAX_PERIODS);
for h in [1usize, 2, 3, 50, 51, 1_000, 1_300, 3_000, 10_000, 100_000, usize::MAX] {
let (d, m) = (default_periods(h), max_periods(h));
assert!(d >= 1 && d <= m && m <= MAX_PERIODS, "{h}: {d} {m}");
assert!(u64::try_from(h).unwrap_or(u64::MAX).saturating_mul(u64::from(m)) <= MAX_WORK.max(u64::try_from(h).unwrap_or(u64::MAX)), "{h}");
}
// Explicit periods or an end past the cap are refused before anything is built.
let mut s = Session::new();
s.new_workbook();
let mut rows = vec![json!(["t", "y"])];
rows.extend((1..=1_300).map(|t| json!([t, (t % 7) as f64 + t as f64 * 0.5])));
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
let sheets = s.doc().unwrap().wb.sheets.len();
let cap = max_periods(1_300); // 384
let err = |r: crate::Result<serde_json::Value>| r.err().map(|e| e.to_string()).unwrap_or_default();
assert!(err(s.execute("data.forecastSheet", json!({"range": "A1:B1301", "periods": cap + 1}))).contains("from 1 to 384"));
assert!(err(s.execute("data.forecastSheet", json!({"range": "A1:B1301", "end": 1_300 + cap + 1}))).contains("at most 384"));
assert!(s.execute("data.forecastSheet", json!({"range": "A1:B1301", "periods": 10_000})).is_err());
assert_eq!(s.doc().unwrap().wb.sheets.len(), sheets);
// More history rows than MAX_HISTORY are refused.
let big = MAX_HISTORY + 2;
let r = s.execute("data.forecastSheet", json!({"timeline": format!("A1:A{big}"), "values": format!("B1:B{big}")}));
assert!(r.is_err());
}
#[test]
fn forecast_sheet_parameters_and_errors() {
let mut s = session_with_history();
let r = s
.execute(
"data.forecastSheet",
json!({"timeline": "A2:A17", "values": "B2:B17", "end": "22", "confidenceInterval": false, "seasonality": 4, "fillMissing": "zeros", "aggregate": "sum", "chart": "column"}),
)
.unwrap();
assert_eq!(r["periods"], 6);
assert_eq!(value(&s, 0, "B1"), Value::text("Values"));
assert_eq!(formula(&s, 0, "C18"), "FORECAST.ETS(A18,$B$2:$B$17,$A$2:$A$17,4,0,7)");
assert_eq!(value(&s, 0, "D1"), Value::Empty);
let sh = s.doc().unwrap().wb.sheet(0).unwrap();
assert_eq!(sh.charts.first().map(|c| c.kind), Some(gridcraft_model::ChartKind::ColumnClustered));
// Errors: end before the data, bad seasonality, uneven timeline, one column only.
s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17", "end": 3})).is_err());
assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17", "seasonality": 9000})).is_err());
assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!B1:B17"})).is_err());
s.execute("cell.set", json!({"cell": "A5", "input": "4.5"})).unwrap();
assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17"})).is_err());
}
}

View File

@ -253,7 +253,15 @@ fn define_name(s: &mut Session, p: &Json) -> Result<Json> {
Some(sh) => Some(d.wb.sheet_index(sh).ok_or_else(|| bad("formulas.defineName", "no such sheet"))?),
};
let refers = match str_param(p, "refersTo") {
Some(r) => r.trim_start_matches('=').to_string(),
// As in Excel, relative references are relative to the active cell; names keep them
// relative to A1, as files do.
Some(r) => match gridcraft_formula::parse(r.trim_start_matches('=')) {
Ok(e) if gridcraft_formula::adjust::has_relative_refs(&e) => {
let at = d.selection.active;
gridcraft_formula::print(&gridcraft_formula::adjust::shift_relative_wrapping(e, -(at.row as i64), -(at.col as i64)))
}
_ => r.trim_start_matches('=').to_string(),
},
None => {
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let r = d.selection.current();
@ -444,7 +452,7 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result<Json> {
fn trace_precedents(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let at = cell_param(p, "cell").unwrap_or(d.selection.active);
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let areas = d.calc.graph.precedents_of((d.wb.active_sheet, at));
let v: Vec<Json> = areas.iter().map(|a| json!({"sheet": d.wb.sheet(a.sheet).map(|s| s.name.clone()), "range": a.range.a1()})).collect();
Ok(Json::Array(v))
@ -452,7 +460,7 @@ fn trace_precedents(s: &mut Session, p: &Json) -> Result<Json> {
fn trace_dependents(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let at = cell_param(p, "cell").unwrap_or(d.selection.active);
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let mut deps = Vec::new();
d.calc.graph.dependents(d.wb.active_sheet, at, &mut deps);
deps.sort();
@ -493,7 +501,7 @@ fn error_checking(s: &mut Session, _: &Json) -> Result<Json> {
fn evaluate_formula(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let at = cell_param(p, "cell").unwrap_or(d.selection.active);
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let sheet = d.wb.active_sheet;
let text = match str_param(p, "formula") {
Some(f) => f.trim_start_matches('=').to_string(),
@ -525,7 +533,7 @@ fn evaluate_formula(s: &mut Session, p: &Json) -> Result<Json> {
fn evaluate(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let f = str_param(p, "formula").ok_or_else(|| bad("formulas.evaluate", "missing `formula`"))?;
let at = cell_param(p, "cell").unwrap_or(d.selection.active);
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let v = gridcraft_calc::evaluate(&d.wb, d.wb.active_sheet, at, f);
Ok(crate::cmd::inspect::value_json(&v))
}
@ -564,3 +572,27 @@ fn calc_options(s: &mut Session, p: &Json) -> Result<Json> {
Ok(json!({"mode": format!("{:?}", cx.wb.calc.mode)}))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names_with_relative_references() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3]]})).unwrap();
// Defined from B2, `=Sheet1!A2` is "the cell to the left".
s.execute("selection.set", json!({"range": "B2"})).unwrap();
let r = s.execute("formulas.defineName", json!({"name": "LeftCell", "refersTo": "=Sheet1!A2"})).unwrap();
assert_eq!(r["refersTo"], "=Sheet1!XFD1");
s.execute("cell.set", json!({"cell": "B1", "input": "=LeftCell"})).unwrap();
s.execute("cell.set", json!({"cell": "B3", "input": "=LeftCell*10"})).unwrap();
let v = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["value"].clone();
assert_eq!(v(&mut s, "B1"), json!(1.0));
assert_eq!(v(&mut s, "B3"), json!(30.0));
// Absolute references are kept as they are.
let r = s.execute("formulas.defineName", json!({"name": "First", "refersTo": "=Sheet1!$A$1"})).unwrap();
assert_eq!(r["refersTo"], "=Sheet1!$A$1");
}
}

View File

@ -666,7 +666,7 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
return Err(EngineError::Other("Select data for the chart first.".into()));
}
let id = d.wb.next_object_id();
let at = cell_param(p, "at").unwrap_or_else(|| CellRef::new(r.start.row, r.end.col + 2));
let at = cell_param_on(s, p, "at", sheet)?.unwrap_or_else(|| CellRef::new(r.start.row, r.end.col + 2));
let title = str_param(p, "title").map(str::to_string).or_else(|| {
if series.len() == 1 {
series[0].name.as_ref().map(|n| gridcraft_calc::evaluate(&d.wb, sheet, at, n).display())
@ -722,6 +722,7 @@ fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> {
fn chart_set(s: &mut Session, p: &Json) -> Result<Json> {
let (si, ci) = find_chart(s, p)?;
let at = cell_param_on(s, p, "at", si)?;
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
let Some(c) = sh.charts.get_mut(ci) else { return Ok(Json::Null) };
@ -755,7 +756,7 @@ fn chart_set(s: &mut Session, p: &Json) -> Result<Json> {
if let Some(t) = p.get("yTitle") {
c.y_title = t.as_str().map(str::to_string);
}
if let Some(at) = cell_param(p, "at") {
if let Some(at) = at {
c.anchor.cell = at;
}
if let Some(w) = f64_param(p, "width") {
@ -838,7 +839,7 @@ fn chart_delete(s: &mut Session, p: &Json) -> Result<Json> {
fn insert_sparkline(s: &mut Session, p: &Json) -> Result<Json> {
let src = str_param(p, "range").ok_or_else(|| bad("insert.sparkline", "missing `range`"))?.to_string();
let loc = cell_param(p, "location").ok_or_else(|| bad("insert.sparkline", "missing `location`"))?;
let loc = cell_param(s, p, "location")?.ok_or_else(|| bad("insert.sparkline", "missing `location`"))?;
let kind = match str_param(p, "type") {
Some("column") => SparklineKind::Column,
Some("winLoss") => SparklineKind::WinLoss,
@ -897,7 +898,7 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
let (w, h) = crate::io::image_size(&data).unwrap_or((320, 240));
let d = s.doc()?;
let id = d.wb.next_object_id();
let at = cell_param(p, "at").unwrap_or(d.selection.active);
let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
let scale = (640.0 / w.max(1) as f32).min(1.0);
let img = Image {
id,
@ -960,7 +961,7 @@ fn insert_shape(s: &mut Session, p: &Json) -> Result<Json> {
};
let d = s.doc()?;
let id = d.wb.next_object_id();
let at = cell_param(p, "at").unwrap_or(d.selection.active);
let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
let fill =
super::format::color_param(p.get("fill")).unwrap_or(if kind == ShapeKind::TextBox { Color::rgb(255, 255, 255) } else { Color::Theme(4, 0) });
let line = super::format::color_param(p.get("line")).unwrap_or(if kind == ShapeKind::TextBox {
@ -982,6 +983,7 @@ fn insert_shape(s: &mut Session, p: &Json) -> Result<Json> {
fill,
line,
text: str_param(p, "text").unwrap_or("").into(),
format: Default::default(),
};
let sheet = d.wb.active_sheet;
edit(s, |cx| {
@ -1030,6 +1032,7 @@ fn delete_object(s: &mut Session, p: &Json) -> Result<Json> {
match kind.as_str() {
"chart" => sh.charts.retain(|c| c.id != id),
"image" => sh.images.retain(|c| c.id != id),
"slicer" => sh.slicers.retain(|c| c.id != id),
_ => sh.shapes.retain(|c| c.id != id),
}
Ok(Json::Null)
@ -1040,16 +1043,18 @@ fn move_object(s: &mut Session, p: &Json) -> Result<Json> {
let id = u32_param(p, "id").ok_or_else(|| bad("object.move", "missing `id`"))?;
let kind = str_param(p, "kind").unwrap_or("").to_string();
let sheet = s.doc()?.wb.active_sheet;
let at = cell_param(s, p, "at")?;
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
let anchor = match kind.as_str() {
"chart" => sh.charts.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
"image" => sh.images.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
"slicer" => sh.slicers.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
"shape" => sh.shapes.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
_ => None,
};
let Some(a) = anchor else { return Err(bad("object.move", "no such object")) };
if let Some(at) = cell_param(p, "at") {
if let Some(at) = at {
a.cell = at;
}
if let Some(v) = f64_param(p, "dx") {
@ -1096,7 +1101,7 @@ fn set_anchor_mode(s: &mut Session, p: &Json) -> Result<Json> {
fn insert_link(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param(p, "cell").unwrap_or(s.doc()?.selection.active);
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
if bool_param(p, "remove").unwrap_or(false) {
return edit(s, |cx| {
cx.sheet_mut(sheet)?.hyperlinks.remove(&at);
@ -1128,7 +1133,7 @@ fn insert_link(s: &mut Session, p: &Json) -> Result<Json> {
fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param(p, "cell").unwrap_or(s.doc()?.selection.active);
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
let text = str_param(p, "text").unwrap_or("").to_string();
let author = str_param(p, "author").map(str::to_string).unwrap_or_else(|| s.prefs.user_name.clone());
edit(s, |cx| {
@ -1142,7 +1147,7 @@ fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result<Json> {
fn reply_comment(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param(p, "cell").unwrap_or(s.doc()?.selection.active);
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
let text = str_param(p, "text").unwrap_or("").to_string();
let author = str_param(p, "author").map(str::to_string).unwrap_or_else(|| s.prefs.user_name.clone());
edit(s, |cx| {
@ -1154,7 +1159,7 @@ fn reply_comment(s: &mut Session, p: &Json) -> Result<Json> {
fn resolve_comment(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param(p, "cell").unwrap_or(s.doc()?.selection.active);
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
let v = bool_param(p, "resolved").unwrap_or(true);
edit(s, |cx| {
if let Some(c) = cx.sheet_mut(sheet)?.comments.get_mut(&at) {
@ -1175,7 +1180,7 @@ fn delete_comment(s: &mut Session, p: &Json) -> Result<Json> {
fn show_note(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param(p, "cell").unwrap_or(s.doc()?.selection.active);
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
edit(s, |cx| {
if let Some(c) = cx.sheet_mut(sheet)?.comments.get_mut(&at) {
c.visible = !c.visible;
@ -1402,7 +1407,7 @@ fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
let size = f64_param(p, "size").unwrap_or(48.0).clamp(8.0, 1000.0) as f32;
let d = s.doc()?;
let id = d.wb.next_object_id();
let at = cell_param(p, "at").unwrap_or(d.selection.active);
let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
let color = super::format::color_param(p.get("color")).unwrap_or(Color::Theme(1, 0));
let shape = Shape {
id,
@ -1411,6 +1416,7 @@ fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
fill: color,
line: Color::Auto,
text: name,
format: Default::default(),
};
let sheet = d.wb.active_sheet;
edit(s, |cx| {

View File

@ -119,7 +119,7 @@ fn cell_get(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let d = s.doc()?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let c = cell_param(p, "cell").unwrap_or(d.selection.active);
let c = cell_param_on(s, p, "cell", sheet)?.unwrap_or(d.selection.active);
let cell = sh.cell(c);
let style = d.wb.styles.get(sh.style_id(c));
Ok(json!({

View File

@ -7,6 +7,7 @@ pub mod draw;
pub mod edit;
pub mod extra;
pub mod file;
pub mod forecast;
pub mod format;
pub mod formulas;
pub mod insert;
@ -15,6 +16,7 @@ pub mod pivot;
pub mod print;
pub mod review;
pub mod sheet;
pub mod slicer;
pub mod solver;
pub mod spelling;
pub mod view;
@ -137,7 +139,9 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(spelling::specs());
v.extend(solver::specs());
v.extend(pivot::specs());
v.extend(slicer::specs());
v.extend(whatif::specs());
v.extend(forecast::specs());
v.extend(analysis::specs());
v.extend(print::specs());
v
@ -238,8 +242,28 @@ pub(crate) fn split_sheet(r: &str) -> (Option<String>, &str) {
}
}
pub(crate) fn cell_param(p: &Json, key: &str) -> Option<CellRef> {
str_param(p, key).and_then(|t| CellRef::parse(split_sheet(t).1))
/// A single-cell parameter. Absent or null is `Ok(None)` (callers default it); a value that is not
/// a cell reference, or that names a sheet other than the active one, is an error rather than a
/// silent fallback to some other cell.
pub(crate) fn cell_param(s: &Session, p: &Json, key: &str) -> Result<Option<CellRef>> {
cell_param_on(s, p, key, s.doc()?.wb.active_sheet)
}
/// Like [`cell_param`], for a command that operates on `sheet` (a `Sheet!` prefix must name it).
pub(crate) fn cell_param_on(s: &Session, p: &Json, key: &str, sheet: usize) -> Result<Option<CellRef>> {
let t = match p.get(key) {
None | Some(Json::Null) => return Ok(None),
Some(Json::String(t)) => t,
Some(v) => return Err(bad("cell", format!("invalid cell reference `{v}` for `{key}`"))),
};
let (sh, body) = split_sheet(t);
let c = CellRef::parse(body).ok_or_else(|| bad("cell", format!("invalid cell reference `{t}` for `{key}`")))?;
if let Some(name) = sh
&& s.doc()?.wb.sheet_index(&name) != Some(sheet)
{
return Err(bad("cell", format!("`{t}` for `{key}` names a sheet other than the one this command works on")));
}
Ok(Some(c))
}
/// Applies an edit to a copy of the active workbook, then recalculates and commits it.

View File

@ -1338,7 +1338,8 @@ fn insert_pivot_chart(s: &mut Session, p: &Json) -> Result<Json> {
let series = pv::chart_series(&sh.name, area.start, &out);
let t = str_param(p, "type").unwrap_or("column");
let kind = super::insert::chart_kind(t, str_param(p, "subtype").unwrap_or(""));
let at = cell_param(p, "at").unwrap_or_else(|| CellRef::new(area.start.row, area.end.col.saturating_add(2).min(gridcraft_core::MAX_COLS - 1)));
let at =
cell_param(s, p, "at")?.unwrap_or_else(|| CellRef::new(area.start.row, area.end.col.saturating_add(2).min(gridcraft_core::MAX_COLS - 1)));
let title = match p.get("title") {
Some(Json::String(t)) => Some(t.clone()),
Some(Json::Null) => None,

View File

@ -1267,7 +1267,7 @@ fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block)
}
let (x, y, w, h) = place(r);
let fill = rgb_of(shp.fill, wb);
let line = rgb_of(shp.line, wb).map(|c| (c, (0.75 * b.k).max(0.25)));
let line = rgb_of(shp.line, wb).filter(|_| !shp.format.no_line).map(|c| (c, (0.75 * b.k).max(0.25)));
match shp.kind {
ShapeKind::Ellipse => {
let pts: Vec<[f32; 2]> = (0..72)
@ -1294,13 +1294,29 @@ fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block)
}
}
if !shp.text.is_empty() {
let size = 11.0 * b.scale;
let lines = wrap_lines(&shp.text, Font::Helvetica, size, (w - 4.0 * b.k).max(size));
let mut yy = y + 2.0 * b.k + size * 0.8;
use gridcraft_model::{ShapeTextAlign, ShapeTextAnchor};
let fm = &shp.format;
let text_box = shp.kind == ShapeKind::TextBox;
let size = fm.font_size.unwrap_or(11.0).clamp(1.0, 4000.0) * b.scale;
let font = Font::styled(Font::family_for(fm.font.as_deref().unwrap_or("")), fm.bold, fm.italic);
let color = fm.text_color.and_then(|c| rgb_of(c, wb)).unwrap_or([0, 0, 0]);
let align = fm.align.unwrap_or(if text_box { ShapeTextAlign::Left } else { ShapeTextAlign::Center });
let anchor = fm.anchor.unwrap_or(if text_box { ShapeTextAnchor::Top } else { ShapeTextAnchor::Middle });
let lines = wrap_lines(&shp.text, font, size, (w - 4.0 * b.k).max(size));
let block_h = lines.len().min(500) as f32 * size * 1.2;
let mut yy = match anchor {
ShapeTextAnchor::Top => y + 2.0 * b.k,
ShapeTextAnchor::Middle => y + (h - block_h) / 2.0,
ShapeTextAnchor::Bottom => y + h - 2.0 * b.k - block_h,
} + size * 0.8;
for l in lines.iter().take(500) {
let lw = gridcraft_pdf::text_width(Font::Helvetica, size, l);
let lx = if shp.kind == ShapeKind::TextBox { x + 2.0 * b.k } else { x + (w - lw) / 2.0 };
pg.text(lx, yy, size, Font::Helvetica, [0, 0, 0], l);
let lw = gridcraft_pdf::text_width(font, size, l);
let lx = match align {
ShapeTextAlign::Left => x + 2.0 * b.k,
ShapeTextAlign::Center => x + (w - lw) / 2.0,
ShapeTextAlign::Right => x + w - 2.0 * b.k - lw,
};
pg.text(lx, yy, size, font, color, l);
yy += size * 1.2;
}
}
@ -1353,11 +1369,11 @@ fn draw_headings(pg: &mut Page, b: &Block, col_heads: bool, row_heads: bool, at:
}
}
fn draw_header_footer(pg: &mut Page, lay: &SheetLayout, cx: &HfContext) {
fn draw_header_footer(pg: &mut Page, lay: &SheetLayout, which: gridcraft_model::HfPage, cx: &HfContext) {
let [l, r, _, _, hm, fm] = lay.margins;
let pw = lay.page_w;
let ph = lay.page_h;
for (spec, header) in [(&lay.settings.header, true), (&lay.settings.footer, false)] {
for (spec, header) in [(lay.settings.hf(which, false), true), (lay.settings.hf(which, true), false)] {
if spec.is_empty() {
continue;
}
@ -1433,7 +1449,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
let mut cf = if sh.cond_formats.is_empty() { None } else { Some(crate::cf::CfCache::new()) };
let k = lay.k();
let (aw, ah) = lay.avail();
for (ri, ci) in &lay.pages {
for (pi, (ri, ci)) in lay.pages.iter().enumerate() {
page_no += 1;
let (Some(rband), Some(cband)) = (lay.row_bands.get(*ri).copied(), lay.col_bands.get(*ci).copied()) else { continue };
let rsegs = segments(lay.title_rows, rband);
@ -1477,7 +1493,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
date: date.clone(),
time: time.clone(),
};
draw_header_footer(pg, lay, &cx);
draw_header_footer(pg, lay, lay.settings.hf_page(pi == 0, page_no), &cx);
}
}
let n = doc.page_count();
@ -1494,7 +1510,7 @@ fn build_pdf(s: &Session, p: &Json, cmd: &str) -> Result<(Vec<u8>, usize)> {
Ok(render_pdf(&d.wb, &lays, &d.display_title(), &path))
}
fn export_pdf(s: &mut Session, p: &Json) -> Result<Json> {
pub(crate) fn export_pdf(s: &mut Session, p: &Json) -> Result<Json> {
let (bytes, pages) = build_pdf(s, p, "file.exportPdf")?;
match str_param(p, "path").filter(|x| !x.is_empty()) {
Some(path) => {
@ -1826,6 +1842,32 @@ mod tests {
assert_eq!(pv["pageList"][2]["range"], "K1:O45");
}
#[test]
fn first_and_even_pages_print_their_own_headers() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["a"], ["b"], ["c"], ["d"], ["e"], ["f"]]})).unwrap();
s.execute("pageLayout.pageSetup", json!({"rowBreaks": [2, 4]})).unwrap();
let r = s
.execute(
"pageLayout.headerFooter",
json!({"header": "&COdd &P", "evenHeader": "&CEven &P", "firstHeader": "&CCover", "differentFirst": true, "differentOddEven": true}),
)
.unwrap();
assert_eq!(r["firstHeader"], "&CCover");
let (pdf, pages) = pdf_of(&mut s, json!({}));
assert_eq!(pages, 3);
assert!(contains(&pdf, "(Cover) Tj") && contains(&pdf, "(Even 2) Tj") && contains(&pdf, "(Odd 3) Tj"));
assert!(!contains(&pdf, "(Odd 1) Tj"), "the first page has its own header");
// One section of the even-page footer, edited in place.
s.execute("pageLayout.headerFooter", json!({"page": "even", "part": "footer", "section": "right", "text": "&[Tab]"})).unwrap();
assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().print.even_footer, "&R&A");
assert!(s.execute("pageLayout.headerFooter", json!({"page": "last", "part": "header", "text": "x"})).is_err());
// Turned off, every page prints the odd header again.
s.execute("pageLayout.headerFooter", json!({"differentFirst": false, "differentOddEven": false})).unwrap();
let (pdf, _) = pdf_of(&mut s, json!({}));
assert!(contains(&pdf, "(Odd 1) Tj") && contains(&pdf, "(Odd 2) Tj") && !contains(&pdf, "(Cover) Tj"));
}
#[test]
fn pdf_contains_text_and_header_codes_expand() {
let mut s = s();
@ -1881,6 +1923,24 @@ mod tests {
assert!(r["base64"].as_str().unwrap().len() > 100);
}
#[test]
fn save_as_pdf_writes_a_pdf_and_keeps_the_workbook_file() {
let mut s = s();
s.execute("cell.set", json!({"cell": "B2", "input": "hello"})).unwrap();
let dir = std::env::temp_dir().join(format!("gridcraft-save-pdf-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Out.PDF");
let r = s.execute("file.saveAs", json!({"path": path.to_str().unwrap()})).unwrap();
assert_eq!(r["pages"], 1);
let bytes = std::fs::read(&path).unwrap();
assert!(bytes.starts_with(b"%PDF-"));
assert!(contains(&bytes, "(hello) Tj"));
// The PDF is a print, not the workbook's file: Save still asks where to put the workbook.
let d = s.doc().unwrap();
assert!(d.path.is_none() && d.is_dirty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rich_sheet_renders() {
let mut s = Session::new();

View File

@ -19,7 +19,15 @@ pub fn specs() -> Vec<CommandSpec> {
protect_sheet
),
cmd!("review.unprotectSheet", "Unprotect Sheet", ["Review", "Protect"], None, "{password?}", has_doc, unprotect_sheet),
cmd!("review.protectWorkbook", "Protect Workbook", ["Review", "Protect"], None, "{on?: bool}", has_doc, protect_workbook),
cmd!(
"review.protectWorkbook",
"Protect Workbook",
["Review", "Protect"],
None,
"{on?: bool (default: toggle), password?} (structure protection; unprotecting needs the password when one was set)",
has_doc,
protect_workbook
),
cmd!(query "review.workbookStatistics", "Workbook Statistics", ["Review", "Proofing"], None, "{}", has_doc, stats),
// Not journaled: the params can hold a password.
CommandSpec {
@ -124,9 +132,16 @@ fn convert_notes(s: &mut Session, _: &Json) -> Result<Json> {
fn protect_sheet(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
// Re-protecting a password-protected sheet would replace the password without knowing it:
// unprotect it first, as in Excel. One without a password can take new options directly.
let has_password = |pr: &SheetProtection| pr.password.is_some() || pr.legacy_password_hash.is_some();
if s.doc()?.wb.sheet(sheet).and_then(|sh| sh.protection.as_ref()).is_some_and(has_password) {
return Err(EngineError::Other("The sheet is protected with a password. Unprotect it first (Review › Unprotect Sheet).".into()));
}
let b = |k: &str| bool_param(p, k).unwrap_or(false);
let prot = SheetProtection {
password_hash: str_param(p, "password").filter(|x| !x.is_empty()).map(password_hash),
password: None,
legacy_password_hash: None,
select_locked: true,
select_unlocked: true,
format_cells: b("formatCells"),
@ -139,6 +154,9 @@ fn protect_sheet(s: &mut Session, p: &Json) -> Result<Json> {
sort: b("sort"),
autofilter: b("autofilter"),
};
let d = s.doc()?;
let what = d.wb.sheet(sheet).map(|sh| sh.name.clone()).unwrap_or_default();
let prot = SheetProtection { password: str_param(p, "password").filter(|x| !x.is_empty()).map(|pw| new_password_hash(d, &what, pw)), ..prot };
edit(s, |cx| {
cx.sheet_mut(sheet)?.protection = Some(prot.clone());
Ok(Json::Null)
@ -147,11 +165,16 @@ fn protect_sheet(s: &mut Session, p: &Json) -> Result<Json> {
fn unprotect_sheet(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let ph = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.protection.as_ref()).and_then(|pr| pr.password_hash);
if let Some(h) = ph
&& str_param(p, "password").map(password_hash) != Some(h)
{
return Err(EngineError::Other("The password you supplied is not correct.".into()));
if let Some(pr) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.protection.as_ref()) {
let pw = str_param(p, "password").unwrap_or("");
let ok = match (&pr.password, pr.legacy_password_hash) {
(Some(h), _) => h.verify(pw).map_err(EngineError::Other)?,
(None, Some(h)) => password_hash(pw) == h,
(None, None) => true,
};
if !ok {
return Err(EngineError::Other("The password you supplied is not correct.".into()));
}
}
edit(s, |cx| {
cx.sheet_mut(sheet)?.protection = None;
@ -159,6 +182,15 @@ fn unprotect_sheet(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// A new Excel-compatible (SHA-512, salted) hash of `pw`. The salt mixes `what` is protected
/// with the document, a counter and the clock, so equal passwords get different hashes.
fn new_password_hash(d: &DocState, what: &str, pw: &str) -> PasswordHash {
static SALT_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let n = SALT_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let seed = format!("{what}\u{0}{n}\u{0}{}\u{0}{}", d.uid, gridcraft_calc::now_serial().to_bits());
PasswordHash::new_sha512(pw, &gridcraft_model::password::salt_from(seed.as_bytes()))
}
fn range_text(r: &EditRange) -> String {
r.ranges.iter().map(|x| x.a1()).collect::<Vec<_>>().join(",")
}
@ -166,7 +198,6 @@ fn range_text(r: &EditRange) -> String {
/// Review › Allow Edit Ranges: list, add, modify or delete the ranges that stay editable on a
/// protected sheet, or unlock a password range for this session.
fn allow_edit_ranges(s: &mut Session, p: &Json) -> Result<Json> {
static SALT_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let sheet = target_sheet(s, p)?;
let action = str_param(p, "action").unwrap_or("list");
let title = str_param(p, "title").map(str::trim).filter(|t| !t.is_empty());
@ -180,11 +211,7 @@ fn allow_edit_ranges(s: &mut Session, p: &Json) -> Result<Json> {
.position(|r| r.title.eq_ignore_ascii_case(t))
.ok_or_else(|| bad("review.allowEditRanges", format!("no range titled `{t}`")))
};
let hash = |pw: &str, title: &str| {
let n = SALT_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let seed = format!("{sheet_name}\u{0}{title}\u{0}{n}\u{0}{}\u{0}{}", d.uid, gridcraft_calc::now_serial().to_bits());
PasswordHash::new_sha512(pw, &gridcraft_model::password::salt_from(seed.as_bytes()))
};
let hash = |pw: &str, title: &str| new_password_hash(d, &format!("{sheet_name}\u{0}{title}"), pw);
if matches!(action, "add" | "modify" | "delete") && sh.is_protected() {
return Err(EngineError::Other("Unprotect the sheet to change its edit ranges.".into()));
}
@ -270,11 +297,28 @@ fn allow_edit_ranges(s: &mut Session, p: &Json) -> Result<Json> {
))
}
/// Review › Protect Workbook: lock the workbook structure (sheets can't be added, moved,
/// renamed, hidden or deleted), optionally with a password that unprotecting must match.
fn protect_workbook(s: &mut Session, p: &Json) -> Result<Json> {
let cur = s.doc()?.wb.protected_structure;
let d = s.doc()?;
let cur = d.wb.protected_structure;
let on = bool_param(p, "on").unwrap_or(!cur);
if on == cur {
return Ok(json!({"on": on}));
}
let password = if on {
str_param(p, "password").filter(|pw| !pw.is_empty()).map(|pw| new_password_hash(d, "workbook", pw))
} else {
if let Some(h) = &d.wb.structure_password
&& !h.verify(str_param(p, "password").unwrap_or("")).map_err(EngineError::Other)?
{
return Err(EngineError::Other("The password you supplied is not correct.".into()));
}
None
};
edit(s, |cx| {
cx.wb.protected_structure = on;
cx.wb.structure_password = password.clone();
Ok(json!({"on": on}))
})
}
@ -355,6 +399,53 @@ mod tests {
assert_eq!(at(&s), (0, "A1".to_string()));
}
#[test]
fn sheet_passwords_use_excel_hashes_and_old_json_hashes_still_verify() {
let mut s = book();
s.execute("review.protectSheet", json!({"password": "pa55"})).unwrap();
let prot = s.doc().unwrap().wb.active().unwrap().protection.clone().unwrap();
assert!(matches!(&prot.password, Some(PasswordHash::Iterated { algorithm, .. }) if algorithm == "SHA-512"));
assert!(s.execute("review.unprotectSheet", json!({})).is_err());
assert!(s.execute("review.unprotectSheet", json!({"password": "pass"})).is_err());
// Protecting again can't swap the password out from under it.
assert!(s.execute("review.protectSheet", json!({})).is_err());
assert_eq!(s.doc().unwrap().wb.active().unwrap().protection.as_ref(), Some(&prot));
s.execute("review.unprotectSheet", json!({"password": "pa55"})).unwrap();
assert!(!s.doc().unwrap().wb.active().unwrap().is_protected());
// A GridCraft JSON workbook saved before carried the internal hash under `password_hash`.
let old = format!(
r#"{{"password_hash": {}, "select_locked": true, "select_unlocked": true, "format_cells": false, "format_columns": false, "format_rows": false, "insert_columns": false, "insert_rows": false, "delete_columns": false, "delete_rows": false, "sort": false, "autofilter": false}}"#,
password_hash("old")
);
let prot: SheetProtection = serde_json::from_str(&old).unwrap();
assert_eq!((prot.legacy_password_hash, prot.password.is_none()), (Some(password_hash("old")), true));
let i = s.doc().unwrap().wb.active_sheet;
let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb);
std::sync::Arc::make_mut(&mut wb.sheets[i]).protection = Some(prot);
assert!(s.execute("review.unprotectSheet", json!({"password": "new"})).is_err());
s.execute("review.unprotectSheet", json!({"password": "old"})).unwrap();
}
#[test]
fn protect_workbook_with_a_password_really_verifies() {
let mut s = book();
s.execute("review.protectWorkbook", json!({"password": "pa55"})).unwrap();
assert!(s.doc().unwrap().wb.protected_structure);
assert!(s.execute("home.insertSheet", json!({"name": "Three"})).is_err(), "the structure is locked");
// A second Protect keeps it protected; unprotecting needs the right password.
assert_eq!(s.execute("review.protectWorkbook", json!({"on": true})).unwrap()["on"], true);
assert!(s.execute("review.protectWorkbook", json!({})).is_err());
assert!(s.execute("review.protectWorkbook", json!({"on": false, "password": "pass"})).is_err());
assert!(s.doc().unwrap().wb.protected_structure);
s.execute("review.protectWorkbook", json!({"on": false, "password": "pa55"})).unwrap();
let wb = &s.doc().unwrap().wb;
assert!(!wb.protected_structure && wb.structure_password.is_none());
// Without a password it toggles freely.
s.execute("review.protectWorkbook", json!({})).unwrap();
s.execute("review.protectWorkbook", json!({})).unwrap();
assert!(!s.doc().unwrap().wb.protected_structure);
}
#[test]
fn convert_to_comments_turns_every_note_into_a_comment_and_undoes() {
let mut s = book();

View File

@ -35,6 +35,7 @@ pub fn specs() -> Vec<CommandSpec> {
p,
Axis::Cols
)),
cmd!("sheet.moveColumns", "Move Columns", [], None, "{cols?: \"B:C\", before: zero-based column index}", has_doc, move_columns),
cmd!("home.insertCells", "Insert Cells…", ["Home", "Cells", "Insert"], None, "{range?, shift: right|down|row|column}", has_doc, insert_cells),
cmd!(
"home.insertCopiedCells",
@ -334,6 +335,105 @@ fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
Ok(out)
}
/// Reorders a contiguous block of whole columns without replacing the destination columns.
/// `before` is a boundary in the original sheet: moving B:C before E produces A,D,B,C,E.
fn move_columns(s: &mut Session, p: &Json) -> Result<Json> {
let (from, count) = lines(s, p, Axis::Cols)?;
let before = u32_param(p, "before").ok_or_else(|| bad("sheet.moveColumns", "missing `before`"))?;
let end =
from.checked_add(count).filter(|end| *end <= MAX_COLS).ok_or_else(|| bad("sheet.moveColumns", "source columns are outside the worksheet"))?;
if before >= MAX_COLS || before.checked_add(count).is_none_or(|end| end > MAX_COLS) {
return Err(bad("sheet.moveColumns", "destination columns are outside the worksheet"));
}
if (from..=end).contains(&before) {
return Ok(json!({"cols": RangeRef::cols(from, end.saturating_sub(1)).a1()}));
}
let sheet = target_sheet(s, p)?;
if let Some(u) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range())
&& u.end.col >= before
&& u.end.col as u64 + count as u64 >= MAX_COLS as u64
{
return Err(EngineError::Other("To prevent possible loss of data, columns cannot be shifted off of the worksheet.".into()));
}
check_lines_protection(s, &json!({"cols": RangeRef::cols(before, before).a1()}), Axis::Cols, before, count, true)?;
check_lines_protection(s, &json!({"cols": RangeRef::cols(from, end - 1).a1()}), Axis::Cols, from, count, false)?;
let moved_from = if before <= from { from + count } else { from };
if moved_from.checked_add(count).is_none_or(|end| end > MAX_COLS) {
return Err(bad("sheet.moveColumns", "the move is too close to the worksheet boundary"));
}
let destination = before;
let delete_at = moved_from;
let final_start = if before > from { before - count } else { before };
let active_row = s.doc()?.selection.active.row;
let source = RangeRef::cols(moved_from, moved_from + count - 1);
let out = edit(s, |cx| {
let name = cx.wb.sheet(sheet).map(|sh| sh.name.clone()).unwrap_or_default();
cx.protection_checked = true;
{
let sh = cx.sheet_mut(sheet)?;
sh.cells.shift_cols(before, count as i64);
shift_sheet_features(sh, Axis::Cols, before, count, true);
}
rewrite(&mut cx.wb, &name, &Edit::Insert { axis: Axis::Cols, at: before, count });
let mut touched = Vec::new();
{
let sh = cx.sheet_mut(sheet)?;
let pictures: Vec<_> =
sh.cell_pictures.iter().filter(|(cell, _)| source.contains(**cell)).map(|(cell, picture)| (*cell, picture.clone())).collect();
let cells = sh.take_cells(source);
let moved_lines: Vec<_> = (0..count).filter_map(|offset| sh.cols.remove(&(moved_from + offset)).map(|info| (offset, info))).collect();
let movement = Edit::Move { from: source, to_row: 0, to_col: destination };
shift_cell_features(sh, &movement);
for object in sh
.charts
.iter_mut()
.map(|o| &mut o.anchor)
.chain(sh.images.iter_mut().map(|o| &mut o.anchor))
.chain(sh.shapes.iter_mut().map(|o| &mut o.anchor))
{
if object.mode != AnchorMode::Absolute && (moved_from..moved_from + count).contains(&object.cell.col) {
object.cell.col = destination + (object.cell.col - moved_from);
}
}
for (cell, value) in cells {
let dest = CellRef::new(cell.row, destination + (cell.col - moved_from));
sh.cells.set(dest, value);
touched.extend([cell, dest]);
}
for (cell, picture) in pictures {
let dest = CellRef::new(cell.row, destination + (cell.col - moved_from));
sh.cell_pictures.insert(dest, picture);
}
for (offset, info) in moved_lines {
sh.cols.insert(destination + offset, info);
}
}
for cell in touched {
cx.touch(sheet, cell);
}
rewrite(&mut cx.wb, &name, &Edit::Move { from: source, to_row: 0, to_col: destination });
{
let sh = cx.sheet_mut(sheet)?;
sh.cells.shift_cols(delete_at + count, -(count as i64));
shift_sheet_features(sh, Axis::Cols, delete_at, count, false);
}
rewrite(&mut cx.wb, &name, &Edit::Delete { axis: Axis::Cols, at: delete_at, count });
*cx.sel = crate::Selection {
active: CellRef::new(active_row, final_start),
anchor: CellRef::new(active_row, final_start),
ranges: vec![RangeRef::cols(final_start, final_start + count - 1)],
};
cx.structural = true;
Ok(json!({"cols": RangeRef::cols(final_start, final_start + count - 1).a1()}))
})?;
cancel_copy_mode(s);
Ok(out)
}
fn insert_cells(s: &mut Session, p: &Json) -> Result<Json> {
let shift = str_param(p, "shift").unwrap_or("down");
let r = target_range(s, p)?;
@ -400,10 +500,7 @@ fn insert_copied_cells(s: &mut Session, p: &Json) -> Result<Json> {
let Some(clip) = s.clipboard.clone() else { return Err(EngineError::Other("Nothing to paste.".into())) };
let sheet = target_sheet(s, p)?;
let d = s.doc()?;
let at = match str_param(p, "at") {
Some(t) => cell_param(p, "at").ok_or_else(|| bad("home.insertCopiedCells", format!("not a cell: {t}")))?,
None => d.selection.current().start,
};
let at = cell_param_on(s, p, "at", sheet)?.unwrap_or(d.selection.current().start);
let shift = str_param(p, "shift").unwrap_or("down");
if shift != "down" && shift != "right" {
return Err(bad("home.insertCopiedCells", format!("unknown `shift`: {shift} (down or right)")));
@ -704,6 +801,7 @@ fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
let name = (2..).map(|k| format!("{base} ({k})")).find(|nm| cx.wb.sheet_index(nm).is_none()).unwrap_or(base.clone());
sh.name = name.chars().take(31).collect();
sh.tables.clear(); // table names must be unique
sh.preserved = None; // kept file content (form controls, OLE objects…) stays with the original
let at = to.min(n);
// The copy gets its own copies of the sheet's names, referring to the copy.
let copies: Vec<gridcraft_model::DefinedName> = cx

View File

@ -0,0 +1,230 @@
//! Slicers: on-sheet widgets that filter one column of a table by clicking value tiles.
//!
//! A slicer is a thin front-end over the existing filter machinery: its selection is written as a
//! [`FilterCriterion::Values`] into the sheet's [`AutoFilter`] (over the table's range) and
//! [`super::data::apply_filter`] hides the rows. Creating a slicer (`insert.slicer` /
//! `table.insertSlicer`) selects everything, i.e. applies no filter.
use gridcraft_core::CellRef;
use gridcraft_model::*;
use serde_json::{Value as Json, json};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"insert.slicer",
"Slicer",
["Insert", "Filters"],
None,
"{table?, column?: \"Region\", columns?: 1, at?: \"H2\", width?, height?}",
has_doc,
insert_slicer
),
cmd!(
"table.insertSlicer",
"Insert Slicer",
["Table", "Tools"],
None,
"{table?, column?: \"Region\", columns?: 1, at?, width?, height?}",
has_doc,
insert_slicer
),
cmd!("slicer.toggle", "Toggle Slicer Value", [], None, "{slicer?: id, value: \"East\", multi?: false}", has_doc, slicer_toggle),
cmd!("slicer.selectAll", "Clear Slicer Filter", [], None, "{slicer?: id}", has_doc, slicer_select_all),
cmd!("slicer.clear", "Clear Slicer Filter", [], None, "{slicer?: id}", has_doc, slicer_select_all),
cmd!("slicer.delete", "Delete Slicer", [], None, "{slicer?: id}", has_doc, slicer_delete),
]
}
/// Distinct, non-blank display values of a table column, in first-seen order. Used to populate a
/// slicer's tiles and to detect "everything selected".
pub fn column_values(wb: &Workbook, sheet: usize, table: &str, column: &str) -> Vec<String> {
let Some(sh) = wb.sheet(sheet) else { return vec![] };
let Some(t) = sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(table)) else { return vec![] };
let Some(ci) = t.column_index(column) else { return vec![] };
let Some(dr) = t.data_range() else { return vec![] };
let col = dr.start.col + ci as u32;
let mut seen = std::collections::BTreeSet::new();
let mut out = Vec::new();
for row in dr.start.row..=dr.end.row {
let text = crate::display::cell_text(wb, sh, CellRef::new(row, col));
if text.is_empty() {
continue;
}
if seen.insert(text.to_lowercase()) {
out.push(text);
}
}
out
}
/// A slicer's tiles as `(value, selected)`, for the UI. `selected` is true for every tile when the
/// slicer has no filter (`selected == None`).
pub fn slicer_items(wb: &Workbook, sheet: usize, sl: &Slicer) -> Vec<(String, bool)> {
column_values(wb, sheet, &sl.table, &sl.column)
.into_iter()
.map(|v| {
let on = match &sl.selected {
None => true,
Some(set) => set.iter().any(|x| x.eq_ignore_ascii_case(&v)),
};
(v, on)
})
.collect()
}
/// Writes the slicer's current selection into the sheet filter and re-applies it.
fn reapply_slicer(wb: &mut Workbook, sheet: usize, id: u32) {
let info = wb.sheet(sheet).and_then(|sh| {
let sl = sh.slicers.iter().find(|s| s.id == id)?;
let t = sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(&sl.table))?;
let ci = t.column_index(&sl.column)?;
Some((t.range, t.range.start.col + ci as u32, sl.selected.clone()))
});
let Some((table_range, abs_col, selected)) = info else { return };
{
let Some(sh) = wb.sheet_mut(sheet) else { return };
let af = sh.autofilter.get_or_insert_with(|| AutoFilter { range: table_range, criteria: vec![] });
let off = abs_col.saturating_sub(af.range.start.col);
af.criteria.retain(|(o, _)| *o != off);
if let Some(values) = selected {
af.criteria.push((off, FilterCriterion::Values { values, blanks: false }));
}
}
super::data::apply_filter(wb, sheet);
}
fn insert_slicer(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let d = s.doc()?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let t = match str_param(p, "table") {
Some(name) => sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(name)),
None => sh.table_at(d.selection.active),
}
.ok_or_else(|| EngineError::Other("Select a cell inside a table first, or pass `table`.".into()))?;
let column = match str_param(p, "column") {
Some(c) => t
.columns
.iter()
.find(|tc| tc.name.eq_ignore_ascii_case(c))
.map(|tc| tc.name.clone())
.ok_or_else(|| bad("insert.slicer", format!("table has no column `{c}`")))?,
None => {
// Default to the active cell's column within the table, else the first column.
let idx = d.selection.active.col.checked_sub(t.range.start.col).map(|o| o as usize).filter(|o| *o < t.columns.len()).unwrap_or(0);
t.columns.get(idx).map(|tc| tc.name.clone()).ok_or_else(|| bad("insert.slicer", "the table has no columns"))?
}
};
let table = t.name.clone();
let at = cell_param(s, p, "at")?
.unwrap_or_else(|| CellRef::new(t.range.start.row, t.range.end.col.saturating_add(2).min(gridcraft_core::MAX_COLS - 1)));
let columns = u32_param(p, "columns").unwrap_or(1).clamp(1, 10);
let width = f64_param(p, "width").unwrap_or(168.0).clamp(60.0, 10000.0) as f32;
let height = f64_param(p, "height").unwrap_or(200.0).clamp(60.0, 10000.0) as f32;
let id = d.wb.next_object_id();
let slicer = Slicer {
id,
table,
column: column.clone(),
caption: column,
anchor: Anchor { cell: at, dx: 4.0, dy: 4.0, width, height, mode: AnchorMode::MoveOnly },
selected: None,
columns,
};
edit(s, |cx| {
cx.sheet_mut(sheet)?.slicers.push(slicer);
Ok(json!({"slicer": id}))
})
}
/// The target slicer: by `slicer` id, else the last one on the active sheet.
fn find_slicer(s: &Session, p: &Json) -> Result<(usize, u32)> {
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let idx = match u32_param(p, "slicer") {
Some(id) => sh.slicers.iter().position(|x| x.id == id),
None => sh.slicers.len().checked_sub(1),
};
idx.and_then(|i| sh.slicers.get(i).map(|x| (i, x.id))).ok_or_else(|| bad("slicer", "no slicer on this sheet"))
}
fn slicer_toggle(s: &mut Session, p: &Json) -> Result<Json> {
let (idx, id) = find_slicer(s, p)?;
let value = str_param(p, "value").ok_or_else(|| bad("slicer.toggle", "missing `value`"))?.to_string();
let multi = bool_param(p, "multi").unwrap_or(false);
let sheet = s.doc()?.wb.active_sheet;
let (table, column, current) = {
let d = s.doc()?;
let sl = d.wb.active().and_then(|sh| sh.slicers.get(idx)).ok_or_else(|| bad("slicer.toggle", "no slicer"))?;
(sl.table.clone(), sl.column.clone(), sl.selected.clone())
};
let all = column_values(&s.doc()?.wb, sheet, &table, &column);
if !all.iter().any(|v| v.eq_ignore_ascii_case(&value)) {
return Err(bad("slicer.toggle", "value is not in the column"));
}
let new_selected: Option<Vec<String>> = if multi {
let mut set: Vec<String> = current.unwrap_or_else(|| all.clone());
match set.iter().position(|x| x.eq_ignore_ascii_case(&value)) {
Some(pos) => {
set.remove(pos);
}
None => set.push(value.clone()),
}
// Everything selected is the same as no filter.
if set.len() == all.len() && all.iter().all(|a| set.iter().any(|x| x.eq_ignore_ascii_case(a))) { None } else { Some(set) }
} else {
// Single click: show only this value; clicking the sole selected value clears the filter.
match &current {
Some(set) if set.len() == 1 && set.iter().any(|x| x.eq_ignore_ascii_case(&value)) => None,
_ => Some(vec![value.clone()]),
}
};
edit(s, |cx| {
if let Some(sl) = cx.sheet_mut(sheet)?.slicers.get_mut(idx) {
sl.selected = new_selected;
}
reapply_slicer(&mut cx.wb, sheet, id);
cx.structural = true;
Ok(Json::Null)
})
}
fn slicer_select_all(s: &mut Session, p: &Json) -> Result<Json> {
let (idx, id) = find_slicer(s, p)?;
let sheet = s.doc()?.wb.active_sheet;
edit(s, |cx| {
if let Some(sl) = cx.sheet_mut(sheet)?.slicers.get_mut(idx) {
sl.selected = None;
}
reapply_slicer(&mut cx.wb, sheet, id);
cx.structural = true;
Ok(Json::Null)
})
}
fn slicer_delete(s: &mut Session, p: &Json) -> Result<Json> {
let (idx, id) = find_slicer(s, p)?;
let sheet = s.doc()?.wb.active_sheet;
// Absolute column of the slicer, so its filter criterion can be removed too.
let abs_col = s.doc().ok().and_then(|d| {
let sh = d.wb.active()?;
let sl = sh.slicers.get(idx)?;
let t = sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(&sl.table))?;
let ci = t.column_index(&sl.column)?;
Some(t.range.start.col + ci as u32)
});
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
sh.slicers.retain(|x| x.id != id);
if let (Some(abs), Some(af)) = (abs_col, sh.autofilter.as_mut()) {
let off = abs.saturating_sub(af.range.start.col);
af.criteria.retain(|(o, _)| *o != off);
}
super::data::apply_filter(&mut cx.wb, sheet);
cx.structural = true;
Ok(Json::Null)
})
}

View File

@ -21,17 +21,23 @@ pub fn specs() -> Vec<CommandSpec> {
fn dictionary() -> &'static HashSet<String> {
static DICT: OnceLock<HashSet<String>> = OnceLock::new();
DICT.get_or_init(|| {
let mut set = HashSet::new();
#[cfg(not(target_arch = "wasm32"))]
for path in ["/usr/share/dict/words", "/usr/share/dict/american-english", "/usr/share/dict/british-english"] {
if let Ok(text) = std::fs::read_to_string(path) {
for w in text.lines().take(2_000_000) {
set.insert(w.trim().to_lowercase());
}
break;
}
#[cfg(target_arch = "wasm32")]
{
HashSet::new()
}
#[cfg(not(target_arch = "wasm32"))]
{
let mut set = HashSet::new();
for path in ["/usr/share/dict/words", "/usr/share/dict/american-english", "/usr/share/dict/british-english"] {
if let Ok(text) = std::fs::read_to_string(path) {
for w in text.lines().take(2_000_000) {
set.insert(w.trim().to_lowercase());
}
break;
}
}
set
}
set
})
}
@ -155,7 +161,7 @@ fn change(s: &mut Session, p: &Json) -> Result<Json> {
if bool_param(p, "all").unwrap_or(false) {
return s.execute("edit.replace", json!({"what": word, "with": to, "matchCase": true, "all": true}));
}
let c = cell_param(p, "cell").ok_or_else(|| bad("review.changeSpelling", "missing `cell`"))?;
let c = cell_param(s, p, "cell")?.ok_or_else(|| bad("review.changeSpelling", "missing `cell`"))?;
let text = s.doc()?.wb.active().and_then(|sh| sh.cell(c)).map(|x| x.input_text()).unwrap_or_default();
s.execute("cell.set", json!({"cell": c.a1(), "input": text.replacen(&word, &to, 1)}))
}

View File

@ -96,7 +96,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Header & Footer",
["Insert", "Text"],
None,
"{sheet?, header?: \"&C&P\", footer?: \"&CPage &P of &N\"} or one section: {sheet?, part: header|footer, section: left|center|right, text: \"Page &[Page] of &[Pages]\"} (field names or codes) → {header, footer}",
"{sheet?, header?: \"&C&P\", footer?: \"&CPage &P of &N\", differentFirst?: bool, differentOddEven?: bool, firstHeader?, firstFooter?, evenHeader?, evenFooter?} or one section: {sheet?, page?: odd|even|first, part: header|footer, section: left|center|right, text: \"Page &[Page] of &[Pages]\"} (field names or codes) → {header, footer, differentFirst, differentOddEven, firstHeader, firstFooter, evenHeader, evenFooter}",
has_doc,
header_footer
),
@ -118,7 +118,7 @@ fn freeze_panes(s: &mut Session, p: &Json) -> Result<Json> {
if sh.freeze.is_some() && p.get("cell").is_none() {
return set_freeze(s, None);
}
let at = cell_param(p, "cell").unwrap_or(d.selection.active);
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let top = sh.view_top_left;
let rows = at.row.saturating_sub(top.row);
let cols = at.col.saturating_sub(top.col);
@ -259,7 +259,7 @@ fn scale_fit(s: &mut Session, p: &Json) -> Result<Json> {
}
fn breaks(s: &mut Session, p: &Json) -> Result<Json> {
let at = cell_param(p, "cell").unwrap_or(s.doc()?.selection.active);
let at = cell_param(s, p, "cell")?.unwrap_or(s.doc()?.selection.active);
let insert = bool_param(p, "insert").unwrap_or(false);
let remove = bool_param(p, "remove").unwrap_or(false);
let reset = bool_param(p, "reset").unwrap_or(false);
@ -308,7 +308,11 @@ fn header_footer(s: &mut Session, p: &Json) -> Result<Json> {
"right" => 2,
other => return Err(bad(CMD, format!("`section` must be left, center or right, not `{other}`"))),
};
Some((footer, idx, super::print::fields_to_codes(str_param(p, "text").unwrap_or(""))))
let page = match str_param(p, "page") {
None => gridcraft_model::HfPage::Odd,
Some(id) => gridcraft_model::HfPage::parse(id).ok_or_else(|| bad(CMD, format!("`page` must be odd, even or first, not `{id}`")))?,
};
Some((page, footer, idx, super::print::fields_to_codes(str_param(p, "text").unwrap_or(""))))
}
};
edit(s, |cx| {
@ -319,15 +323,40 @@ fn header_footer(s: &mut Session, p: &Json) -> Result<Json> {
if let Some(f) = str_param(p, "footer") {
pr.footer = cap(f);
}
if let Some((footer, idx, text)) = &section {
let spec = if *footer { &mut pr.footer } else { &mut pr.header };
for (key, slot) in [
("firstHeader", &mut pr.first_header),
("firstFooter", &mut pr.first_footer),
("evenHeader", &mut pr.even_header),
("evenFooter", &mut pr.even_footer),
] {
if let Some(t) = str_param(p, key) {
*slot = cap(t);
}
}
if let Some(on) = bool_param(p, "differentFirst") {
pr.different_first = on;
}
if let Some(on) = bool_param(p, "differentOddEven") {
pr.different_odd_even = on;
}
if let Some((page, footer, idx, text)) = &section {
let spec = pr.hf_mut(*page, *footer);
let mut parts = super::print::split_header_footer(spec);
if let Some(slot) = parts.get_mut(*idx) {
*slot = text.clone();
}
*spec = cap(&super::print::join_header_footer(&parts));
}
Ok(json!({"header": pr.header, "footer": pr.footer}))
Ok(json!({
"header": pr.header,
"footer": pr.footer,
"differentFirst": pr.different_first,
"differentOddEven": pr.different_odd_even,
"firstHeader": pr.first_header,
"firstFooter": pr.first_footer,
"evenHeader": pr.even_header,
"evenFooter": pr.even_footer,
}))
})
}

View File

@ -31,6 +31,37 @@ pub fn format(v: &Value, code: &str, wb: &Workbook) -> Formatted {
format_value(v, &parsed(code), wb.date_system)
}
/// What the cell editor and the formula bar start from. Same as `Cell::input_text`, except that a
/// constant number in a date or time format is shown as a date (as the grid shows it), not as its
/// serial. When the formatted text would not read back as the same date (for example
/// `dddd, mmmm d`), an ISO `yyyy-mm-dd` (with `hh:mm:ss` for a time of day; just `hh:mm:ss`
/// for a time alone) is used instead, and if even that would not read back, the serial.
pub fn edit_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String {
let raw = sheet.input_text(c);
let Some(cell) = sheet.cell(c) else { return raw };
let Value::Number(n) = &cell.value else { return raw };
let code = wb.styles.get(cell.style).num_fmt.as_str();
if cell.formula.is_some() || !parsed(code).is_date() {
return raw;
}
let reads_back = |s: &str| matches!(gridcraft_core::parse::parse_input(s, wb.date_system).value, Value::Number(m) if (m - n).abs() < 1e-9);
let shown = format(&cell.value, code, wb).text;
if reads_back(&shown) {
return shown;
}
let Some(dt) = gridcraft_core::date::datetime_from_serial(wb.date_system, *n) else { return raw };
let time = format!("{:02}:{:02}:{:02}", dt.hour, dt.minute, dt.second);
let date = format!("{:04}-{:02}-{:02}", dt.year, dt.month, dt.day);
let iso = if *n < 1.0 {
time
} else if n.fract() == 0.0 {
date
} else {
format!("{date} {time}")
};
if reads_back(&iso) { iso } else { raw }
}
/// Whether `c` has a formula that must not be shown: its cell is formatted Hidden on a
/// protected sheet. A spilled cell follows its anchor.
pub fn formula_hidden(wb: &Workbook, sheet: &Sheet, c: CellRef) -> bool {

View File

@ -1,4 +1,4 @@
//! Opening and saving files: XLSX, XLSB and ODS data import, CSV/TSV, JSON (debug), HTML export.
//! Opening and saving files: XLSX, legacy XLS import, XLSB and ODS data import, CSV/TSV, JSON (debug), HTML export.
use std::fmt::Write as _;
@ -11,6 +11,8 @@ use crate::{EngineError, Result};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FileKind {
Xlsx,
/// Excel 97-2003 workbook (import only).
Xls,
Xlsb,
Ods,
Csv,
@ -24,6 +26,7 @@ impl FileKind {
let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
Some(match ext.as_str() {
"xlsx" | "xlsm" | "xltx" | "xltm" => FileKind::Xlsx,
"xls" | "xlt" => FileKind::Xls,
"xlsb" => FileKind::Xlsb,
"ods" => FileKind::Ods,
"csv" => FileKind::Csv,
@ -45,9 +48,13 @@ pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
"'{name}' is password-protected. GridCraft can't open encrypted workbooks yet; remove the password in Excel and try again."
)));
}
if sniffed == gridcraft_xlsx::Format::Xls {
let (wb, report) = gridcraft_xlsx::read_xls(bytes).map_err(|e| EngineError::Other(format!("We can't open '{name}': {e}")))?;
return Ok((wb, report.warnings));
}
if sniffed == gridcraft_xlsx::Format::LegacyBinary {
return Err(EngineError::Other(format!(
"'{name}' is an Excel 97-2003 (.xls) workbook or another legacy binary file. GridCraft doesn't open these yet; save it as .xlsx in Excel and try again."
"'{name}' is a legacy binary file but not an Excel workbook (no workbook stream). GridCraft can't open it."
)));
}
// Content wins over the extension: an ODS or XLSB package is imported as what it is,
@ -106,7 +113,17 @@ fn rename_first_sheet(wb: &mut Workbook, path: &str) {
pub fn save_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
let sheet = wb.active_sheet;
match FileKind::from_path(path).unwrap_or(FileKind::Xlsx) {
// A macro-free .xlsx can't hold a VBA project (Excel refuses such a file), so only a .xlsm
// keeps the macros; any other name saves without them, as Excel does.
FileKind::Xlsx if wb.vba_project.is_some() && !path.to_ascii_lowercase().ends_with(".xlsm") => {
let mut plain = wb.clone();
plain.vba_project = None;
gridcraft_xlsx::write_xlsx(&plain).map_err(|e| EngineError::Other(e.to_string()))
}
FileKind::Xlsx => gridcraft_xlsx::write_xlsx(wb).map_err(|e| EngineError::Other(e.to_string())),
FileKind::Xls => {
Err(EngineError::Other("Excel 97-2003 (.xls) is supported for import only. Save as .xlsx or another supported export format.".into()))
}
FileKind::Xlsb => Err(EngineError::Other("XLSB is supported for data import only. Save as .xlsx or another supported export format.".into())),
FileKind::Ods => Err(EngineError::Other("ODS is supported for data import only. Save as .xlsx or another supported export format.".into())),
FileKind::Csv => Ok(wb.sheet(sheet).map(|sh| gridcraft_xlsx::write_csv(sh, wb, b',')).unwrap_or_default()),
@ -356,6 +373,20 @@ pub fn image_size(data: &[u8]) -> Option<(u32, u32)> {
mod tests {
use super::*;
#[test]
fn macros_are_saved_only_into_xlsm() {
let mut wb = Workbook::new();
wb.vba_project = Some(b"vba".to_vec());
let has_macros = |path: &str| {
let bytes = save_bytes(&wb, path).unwrap();
gridcraft_xlsx::read_xlsx(&bytes).unwrap().0.vba_project.is_some()
};
assert!(has_macros("book.xlsm"));
assert!(has_macros("BOOK.XLSM"));
assert!(!has_macros("book.xlsx"), "an .xlsx never carries a VBA project");
assert!(wb.vba_project.is_some(), "the open workbook keeps its macros");
}
/// A minimal compound (CFB/OLE2) file — FAT in sector 0, directory in sector 1 — holding a
/// root entry and the given stream names.
fn cfb(streams: &[&str]) -> Vec<u8> {
@ -388,10 +419,10 @@ mod tests {
}
#[test]
fn legacy_xls_is_not_called_encrypted() {
let err = open_bytes("old.xls", &cfb(&["Workbook", "\u{5}SummaryInformation"])).unwrap_err();
fn other_compound_files_are_not_called_encrypted() {
let err = open_bytes("old.doc", &cfb(&["WordDocument", "\u{5}SummaryInformation"])).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("97-2003") && msg.contains(".xlsx"), "got: {msg}");
assert!(msg.contains("not an Excel workbook"), "got: {msg}");
assert!(!msg.contains("password"), "got: {msg}");
}
}

View File

@ -13,6 +13,7 @@ pub mod cmd;
pub mod display;
pub mod fill;
pub mod io;
pub mod outline;
pub mod paper;
pub mod pivot;
pub mod pivot_calc;
@ -139,6 +140,35 @@ impl DocState {
pub fn sheet_index(&self) -> usize {
self.wb.active_sheet
}
/// Records the view state that is saved with the file (`sheetView topLeftCell`, the active
/// cell): the first visible cell of the scrolling area per sheet index in `tops`, and the
/// active sheet's active cell. View state never makes the workbook dirty.
pub fn store_view(&mut self, tops: &[(usize, CellRef)]) {
let active = self.selection.active;
let si = self.wb.active_sheet;
let changed = tops.iter().any(|(i, c)| self.wb.sheets.get(*i).is_some_and(|sh| sh.view_top_left != *c))
|| self.wb.sheets.get(si).is_some_and(|sh| sh.view_active != active);
if !changed {
return;
}
let dirty = self.is_dirty();
let wb = Arc::make_mut(&mut self.wb);
for (i, c) in tops {
if let Some(sh) = wb.sheets.get_mut(*i)
&& sh.view_top_left != *c
{
Arc::make_mut(sh).view_top_left = *c;
}
}
if let Some(sh) = wb.sheets.get_mut(si)
&& sh.view_active != active
{
Arc::make_mut(sh).view_active = active;
}
if !dirty {
self.saved = self.wb.clone();
}
}
pub fn display_title(&self) -> String {
self.path
.as_deref()

View File

@ -0,0 +1,133 @@
//! Outline groups (Data ▸ Group) of a sheet's rows or columns: the runs of lines at or above
//! each outline level, as the grid draws them (brackets, − / + buttons, level buttons) and the
//! Show / Hide Detail commands act on them. Summary rows sit below their detail and summary
//! columns right of it (Excel's defaults; the model has no setting for the other way round).
use std::collections::BTreeMap;
use gridcraft_model::LineInfo;
/// The deepest outline level XLSX allows.
pub const MAX_LEVEL: u8 = 7;
/// One group: lines `start..=end` at outline level `level` or deeper, with its summary line
/// just after `end`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct OutlineGroup {
pub level: u8,
pub start: u32,
pub end: u32,
/// Every detail line is hidden: the button shows +.
pub collapsed: bool,
}
impl OutlineGroup {
/// The summary line, where the − / + button sits.
pub fn summary(&self) -> u32 {
self.end.saturating_add(1)
}
pub fn contains(&self, line: u32) -> bool {
line >= self.start && line <= self.end
}
}
/// The deepest outline level used by these lines (0 = no outline).
pub fn max_level(lines: &BTreeMap<u32, LineInfo>) -> u8 {
lines.values().map(|l| l.outline.min(MAX_LEVEL)).max().unwrap_or(0)
}
/// Every group, ordered by where it ends (inner groups before the group around them).
pub fn groups(lines: &BTreeMap<u32, LineInfo>) -> Vec<OutlineGroup> {
let mut out = Vec::new();
// Open group per level (index level - 1): start line and whether all its lines are hidden.
let mut open: [Option<(u32, bool)>; MAX_LEVEL as usize] = [None; MAX_LEVEL as usize];
let mut prev: Option<u32> = None;
let close_from = |open: &mut [Option<(u32, bool)>; MAX_LEVEL as usize], from: u8, end: u32, out: &mut Vec<OutlineGroup>| {
for level in (from..=MAX_LEVEL).rev() {
if let Some(slot) = open.get_mut(level as usize - 1)
&& let Some((start, hidden)) = slot.take()
{
out.push(OutlineGroup { level, start, end, collapsed: hidden });
}
}
};
for (&i, info) in lines.iter().filter(|(_, l)| l.outline > 0) {
let level = info.outline.min(MAX_LEVEL);
match prev {
// A gap ends every group.
Some(p) if p.checked_add(1) != Some(i) => close_from(&mut open, 1, p, &mut out),
Some(p) if level < MAX_LEVEL => close_from(&mut open, level + 1, p, &mut out),
_ => {}
}
for slot in open.iter_mut().take(level as usize) {
match slot {
Some((_, hidden)) => *hidden &= info.hidden,
None => *slot = Some((i, info.hidden)),
}
}
prev = Some(i);
}
if let Some(p) = prev {
close_from(&mut open, 1, p, &mut out);
}
out
}
/// The group at `level` that holds `line`.
pub fn group_at(lines: &BTreeMap<u32, LineInfo>, line: u32, level: u8) -> Option<OutlineGroup> {
groups(lines).into_iter().find(|g| g.level == level && g.contains(line))
}
#[cfg(test)]
mod tests {
use super::*;
fn lines(spec: &[(u32, u8, bool)]) -> BTreeMap<u32, LineInfo> {
spec.iter().map(|&(i, outline, hidden)| (i, LineInfo { outline, hidden, ..Default::default() })).collect()
}
#[test]
fn no_outline_has_no_groups() {
let l = lines(&[(3, 0, true)]);
assert_eq!(max_level(&l), 0);
assert!(groups(&l).is_empty());
}
#[test]
fn nested_and_separate_groups() {
// Rows 5..8 at level 1 with 6..7 at level 2; rows 12..13 at level 1 after a gap.
let l = lines(&[(4, 1, false), (5, 2, false), (6, 2, false), (7, 1, false), (11, 1, true), (12, 1, true)]);
assert_eq!(max_level(&l), 2);
let g = groups(&l);
assert_eq!(
g,
vec![
OutlineGroup { level: 2, start: 5, end: 6, collapsed: false },
OutlineGroup { level: 1, start: 4, end: 7, collapsed: false },
OutlineGroup { level: 1, start: 11, end: 12, collapsed: true },
]
);
assert_eq!(g[1].summary(), 8);
assert_eq!(group_at(&l, 6, 1).map(|g| g.start), Some(4));
assert_eq!(group_at(&l, 4, 2), None);
}
#[test]
fn adjacent_groups_at_one_level_split_by_a_shallower_line() {
// Two level-2 groups inside one level-1 group, separated by a level-1 line.
let l = lines(&[(0, 2, true), (1, 2, true), (2, 1, false), (3, 2, false)]);
let g = groups(&l);
assert_eq!(g.iter().filter(|g| g.level == 2).count(), 2);
assert_eq!(g.iter().find(|g| g.level == 2 && g.start == 0).map(|g| g.collapsed), Some(true));
assert_eq!(g.iter().find(|g| g.level == 1).map(|g| (g.start, g.end, g.collapsed)), Some((0, 3, false)));
}
#[test]
fn deepest_level_and_last_line_do_not_overflow() {
let l = lines(&[(u32::MAX - 1, 9, false), (u32::MAX, 7, false)]);
let g = groups(&l);
assert_eq!(g.len(), 7);
assert!(g.iter().all(|g| g.start == u32::MAX - 1 && g.end == u32::MAX));
assert_eq!(g[0].summary(), u32::MAX);
}
}

View File

@ -560,6 +560,58 @@ fn insert_delete_rows_adjust() {
assert_eq!(v(&s, "A1"), Value::Number(10.0));
}
#[test]
fn move_columns_reorders_without_overwriting_and_undoes_once() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3, 4, 5, 6, 7, 8]]})).unwrap();
s.execute("cell.set", json!({"cell": "H2", "input": "=B1+D1"})).unwrap();
s.execute("cell.set", json!({"cell": "H3", "input": "=SUM(B:B)"})).unwrap();
s.execute("home.columnWidth", json!({"cols": "B:B", "width": 101})).unwrap();
s.execute("home.columnWidth", json!({"cols": "C:C", "width": 102})).unwrap();
let before = s.doc().unwrap().wb.clone();
s.doc_mut().unwrap().undo.clear();
let result = s.execute("sheet.moveColumns", json!({"cols": "B:C", "before": 4})).unwrap();
assert_eq!(result["cols"], "C:D");
assert_eq!(
(0..8).map(|col| v(&s, &CellRef::new(0, col).a1())).collect::<Vec<_>>(),
vec![
Value::Number(1.0),
Value::Number(4.0),
Value::Number(2.0),
Value::Number(3.0),
Value::Number(5.0),
Value::Number(6.0),
Value::Number(7.0),
Value::Number(8.0),
]
);
assert_eq!(s.execute("cell.get", json!({"cell": "H2"})).unwrap()["formula"], "=C1+B1");
assert_eq!(s.execute("cell.get", json!({"cell": "H3"})).unwrap()["formula"], "=SUM(C:C)");
let sh = s.doc().unwrap().wb.active().unwrap();
assert_eq!(sh.col_width(2), 101.0);
assert_eq!(sh.col_width(3), 102.0);
assert_eq!(s.doc().unwrap().selection.current().a1(), "C:D");
assert_eq!(s.doc().unwrap().undo.len(), 1);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(*s.doc().unwrap().wb, *before);
}
#[test]
fn move_columns_can_reorder_left_and_ignores_drops_inside_the_selection() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["A", "B", "C", "D", "E"]]})).unwrap();
s.execute("sheet.moveColumns", json!({"cols": "D:E", "before": 1})).unwrap();
assert_eq!(
(0..5).map(|col| v(&s, &CellRef::new(0, col).a1())).collect::<Vec<_>>(),
vec!["A".into(), "D".into(), "E".into(), "B".into(), "C".into()]
);
let undo = s.doc().unwrap().undo.len();
s.execute("sheet.moveColumns", json!({"cols": "B:C", "before": 2})).unwrap();
assert_eq!(s.doc().unwrap().undo.len(), undo);
}
#[test]
fn insert_copied_cells_inserts_rows_columns_and_blocks() {
// Issue #21: Insert Copied Cells.
@ -803,7 +855,7 @@ fn protection_passwords_stay_out_of_the_journal_and_recorded_scripts() {
r.execute(id, p.clone()).unwrap_or_else(|e| panic!("{id}: {e}"));
let prot = r.doc().unwrap().wb.active().unwrap().protection.clone();
if id == "review.protectSheet" {
assert!(prot.as_ref().is_some_and(|p| p.format_cells && p.password_hash.is_none()), "{prot:?}");
assert!(prot.as_ref().is_some_and(|p| p.format_cells && p.password.is_none()), "{prot:?}");
}
}
assert!(r.doc().unwrap().wb.active().unwrap().protection.is_none());
@ -966,6 +1018,59 @@ fn tables_and_structured_refs() {
assert_eq!(v(&s, "B4"), Value::Number(7.0));
}
#[test]
fn slicer_filters_table() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Qty"], ["East", 2], ["West", 5], ["East", 3], ["North", 1]]})).unwrap();
s.execute("insert.table", json!({"range": "A1:B5", "name": "Sales"})).unwrap();
s.execute("selection.set", json!({"cell": "A2"})).unwrap();
// A new slicer on the Region column filters nothing (all values selected).
let r = s.execute("insert.slicer", json!({})).unwrap();
let id = r["slicer"].as_u64().unwrap() as u32;
let hidden = |s: &Session| {
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
(1..=4).filter(|row| sh.is_row_hidden(*row)).count() // data rows are sheet rows 1..=4 (0-based header at 0)
};
assert_eq!(hidden(&s), 0);
{
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
assert_eq!(sh.slicers.len(), 1);
assert_eq!(sh.slicers[0].column, "Region");
}
// Single-click "East": only the two East rows remain (West row 2 and North row 4 hidden).
s.execute("slicer.toggle", json!({"slicer": id, "value": "East"})).unwrap();
assert_eq!(hidden(&s), 2);
{
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
assert!(sh.is_row_hidden(2) && sh.is_row_hidden(4));
assert!(!sh.is_row_hidden(1) && !sh.is_row_hidden(3));
}
// Ctrl-click "West" adds it: East + West visible, only North hidden.
s.execute("slicer.toggle", json!({"slicer": id, "value": "West", "multi": true})).unwrap();
assert_eq!(hidden(&s), 1);
// Clear restores everything.
s.execute("slicer.clear", json!({"slicer": id})).unwrap();
assert_eq!(hidden(&s), 0);
assert!(s.doc().unwrap().wb.active().unwrap().slicers[0].selected.is_none());
// Deleting the slicer drops it and clears its filter.
s.execute("slicer.toggle", json!({"slicer": id, "value": "East"})).unwrap();
assert_eq!(hidden(&s), 2);
s.execute("slicer.delete", json!({"slicer": id})).unwrap();
assert_eq!(hidden(&s), 0);
assert!(s.doc().unwrap().wb.active().unwrap().slicers.is_empty());
// A bad value is rejected, not panicked.
assert!(s.execute("slicer.toggle", json!({"value": "Nowhere"})).is_err());
}
#[test]
fn create_names_from_selection() {
let names = |s: &Session| -> Vec<(String, String)> {
@ -1191,6 +1296,19 @@ fn ods_import_never_reuses_source_as_save_target() {
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn validation_formulas_are_relative_and_see_the_new_value() {
let mut s = s();
s.execute("range.setValues", json!({"range": "B1", "values": [[1], [100]]})).unwrap();
s.execute("data.validation", json!({"range": "A1:A2", "type": "custom", "formula1": "=A1>10"})).unwrap();
s.execute("data.validation", json!({"range": "C1:C2", "type": "whole", "operator": "lessOrEqual", "formula1": "=B1"})).unwrap();
let ok = |s: &mut Session, cell: &str, input: &str| s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == true;
assert!(ok(&mut s, "A1", "20"));
assert!(!ok(&mut s, "A2", "5"));
assert!(ok(&mut s, "C2", "50"));
assert!(!ok(&mut s, "C1", "50"));
}
#[test]
fn samples_build() {
for (name, _) in crate::sample::SAMPLES {
@ -1629,6 +1747,91 @@ fn xlsb_import_never_reuses_source_as_save_target() {
std::fs::remove_dir_all(dir).unwrap();
}
/// A one-sheet Excel 97-2003 workbook ([MS-CFB] container, [MS-XLS] BIFF8 stream) built in code:
/// A1 = 42 and A2 = `A1*2` with its cached result.
fn xls_fixture() -> Vec<u8> {
fn rec(id: u16, payload: &[u8]) -> Vec<u8> {
[&id.to_le_bytes()[..], &(payload.len() as u16).to_le_bytes(), payload].concat()
}
let bof = |dt: u16| rec(0x0809, &[&0x0600u16.to_le_bytes()[..], &dt.to_le_bytes(), &[0; 12]].concat());
let sheet = [
bof(0x0010),
rec(0x0203, &[&[0u8, 0, 0, 0, 0, 0][..], &42f64.to_le_bytes()].concat()),
rec(0x0006, &[&[1u8, 0, 0, 0, 0, 0][..], &84f64.to_le_bytes(), &[0; 6], &[9, 0, 0x44, 0, 0, 0, 0xC0, 0x1E, 2, 0, 0x05]].concat()),
rec(0x000A, &[]),
]
.concat();
let globals = |pos: u32| [bof(0x0005), rec(0x0085, &[&pos.to_le_bytes()[..], &[0, 0, 3, 0], b"Old"].concat()), rec(0x000A, &[])].concat();
let stream = [globals(globals(0).len() as u32), sheet].concat();
// Sectors: 0 FAT, 1 directory, 2 mini FAT, 3 mini stream container.
let mut b = vec![0u8; 512 * 5];
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());
put(30, &9u16.to_le_bytes());
put(32, &6u16.to_le_bytes());
put(44, &1u32.to_le_bytes());
put(48, &1u32.to_le_bytes());
put(56, &4096u32.to_le_bytes());
put(60, &2u32.to_le_bytes());
put(64, &1u32.to_le_bytes());
put(68, &0xFFFF_FFFEu32.to_le_bytes());
for i in 1..109 {
put(76 + i * 4, &0xFFFF_FFFFu32.to_le_bytes());
}
for (i, v) in [0xFFFF_FFFDu32, 0xFFFF_FFFE, 0xFFFF_FFFE, 0xFFFF_FFFE].iter().enumerate() {
put(512 + i * 4, &v.to_le_bytes());
}
for i in 4..128 {
put(512 + i * 4, &0xFFFF_FFFFu32.to_le_bytes());
}
let minis = stream.len().div_ceil(64);
for i in 0..128 {
let next = if i + 1 < minis {
i as u32 + 1
} else if i + 1 == minis {
0xFFFF_FFFE
} else {
0xFFFF_FFFF
};
put(512 * 3 + i * 4, &next.to_le_bytes());
}
let entry = |k: usize, name: &str, kind: u8, child: u32, start: u32, size: u32, put: &mut dyn FnMut(usize, &[u8])| {
let at = 512 * 2 + k * 128;
let units: Vec<u8> = name.encode_utf16().flat_map(u16::to_le_bytes).collect();
put(at, &units);
put(at + 64, &(units.len() as u16 + 2).to_le_bytes());
put(at + 66, &[kind, 1]);
put(at + 68, &[0xFF; 8]);
put(at + 76, &child.to_le_bytes());
put(at + 116, &start.to_le_bytes());
put(at + 120, &size.to_le_bytes());
};
entry(0, "Root Entry", 5, 1, 3, 512, &mut put);
entry(1, "Workbook", 2, 0xFFFF_FFFF, 0, stream.len() as u32, &mut put);
put(512 * 4, &stream);
b
}
#[test]
fn xls_import_keeps_formulas_and_never_saves_back_to_xls() {
let bytes = xls_fixture();
assert_eq!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Xls);
let mut s = s();
let r = s.execute("file.open", json!({"name": "legacy.xls", "base64": crate::io::base64_encode(&bytes)})).unwrap();
assert!(r["warnings"][0].as_str().unwrap().contains("97-2003"));
assert_eq!(r["sheets"], json!(["Old"]));
let d = s.doc().unwrap();
assert!(d.path.is_none());
assert_eq!(d.display_title(), "legacy (imported).xlsx");
assert_eq!(v(&s, "A2"), Value::Number(84.0));
// The formula is live: editing its input recalculates it.
s.execute("cell.set", json!({"cell": "A1", "input": "50"})).unwrap();
assert_eq!(v(&s, "A2"), Value::Number(100.0));
assert!(s.execute("file.saveBytes", json!({"format": "xls"})).is_err());
assert!(s.execute("file.saveBytes", json!({"format": "xlsx"})).is_ok());
}
#[test]
fn malformed_xlsb_keeps_the_current_workbook() {
let mut s = s();
@ -2168,3 +2371,31 @@ fn copying_areas_that_do_not_line_up_is_refused_with_a_message() {
assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "link": true, "at": "E1"})).is_err());
assert!(v(&s, "E1").is_empty());
}
#[test]
fn editing_a_date_cell_shows_a_date_not_the_serial() {
let mut s = s();
let at = CellRef::parse("A1").unwrap();
s.execute("cell.set", json!({"cell": "A1", "input": "2026-03-04"})).unwrap();
{
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
assert_eq!(crate::display::edit_text(&d.wb, sh, at), "2026-03-04");
}
// A format whose text cannot be typed back falls back to ISO.
s.execute("home.formatCells", json!({"range": "A1", "style": {"num_fmt": "dddd, mmmm d"}})).unwrap();
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
assert_eq!(crate::display::edit_text(&d.wb, sh, at), "2026-03-04");
// A time alone falls back to a time, not to day 0 of 1900 ("9 AM" would lose the 36 minutes).
let mut s3 = self::s();
s3.execute("cell.set", json!({"cell": "A1", "input": "0.4"})).unwrap();
s3.execute("home.formatCells", json!({"range": "A1", "style": {"num_fmt": "h AM/PM"}})).unwrap();
let d = s3.doc().unwrap();
assert_eq!(crate::display::edit_text(&d.wb, d.wb.active().unwrap(), at), "09:36:00");
// Plain numbers are untouched.
let mut s2 = self::s();
s2.execute("cell.set", json!({"cell": "A1", "input": "42"})).unwrap();
let d = s2.doc().unwrap();
assert_eq!(crate::display::edit_text(&d.wb, d.wb.active().unwrap(), at), "42");
}

View File

@ -16,6 +16,35 @@ pub fn shift_relative(e: Expr, dr: i64, dc: i64) -> Expr {
})
}
/// Defined names: relative parts move by `(dr, dc)`, wrapping around the sheet's edges. Files
/// store a name's relative references as seen from A1 (a name for "the cell to the left" defined
/// in B1 is `Sheet1!XFD1`), and a use in another cell moves them by that cell's offset from A1.
pub fn shift_relative_wrapping(e: Expr, dr: i64, dc: i64) -> Expr {
let wrap = |v: u32, abs: bool, d: i64, max: u32| if abs { v } else { (v as i64 + d).rem_euclid(max as i64) as u32 };
let anchor = |a: Anchor| Anchor { row: wrap(a.row, a.row_abs, dr, MAX_ROWS), col: wrap(a.col, a.col_abs, dc, MAX_COLS), ..a };
e.map(&mut |x| match x {
Expr::Ref(r) => {
let kind = match r.kind {
RefKind::Cell(a) => RefKind::Cell(anchor(a)),
RefKind::Range(a, b) => RefKind::Range(anchor(a), anchor(b)),
RefKind::Rows(r0, a0, r1, a1) => RefKind::Rows(wrap(r0, a0, dr, MAX_ROWS), a0, wrap(r1, a1, dr, MAX_ROWS), a1),
RefKind::Cols(c0, a0, c1, a1) => RefKind::Cols(wrap(c0, a0, dc, MAX_COLS), a0, wrap(c1, a1, dc, MAX_COLS), a1),
};
Expr::Ref(Reference { sheet: r.sheet, kind })
}
other => other,
})
}
/// Whether the formula has a relative (no `$`) row or column anywhere.
pub fn has_relative_refs(e: &Expr) -> bool {
e.references().iter().any(|r| match r.kind {
RefKind::Cell(a) => !a.row_abs || !a.col_abs,
RefKind::Range(a, b) => !a.row_abs || !a.col_abs || !b.row_abs || !b.col_abs,
RefKind::Rows(_, a0, _, a1) | RefKind::Cols(_, a0, _, a1) => !a0 || !a1,
})
}
fn shift_anchor(a: &Anchor, dr: i64, dc: i64) -> Option<Anchor> {
let row = if a.row_abs { a.row as i64 } else { a.row as i64 + dr };
let col = if a.col_abs { a.col as i64 } else { a.col as i64 + dc };
@ -195,17 +224,6 @@ fn adjust_ref(r: &Reference, edit: &Edit) -> Option<Reference> {
let kind = match &r.kind {
RefKind::Cell(a) => RefKind::Cell(mv(a)?),
RefKind::Range(a, b) => RefKind::Range(mv(a)?, mv(b)?),
k => k.clone(),
};
Some(Reference { sheet: r.sheet.clone(), kind })
}
Edit::MoveToSheet { from, to_sheet, to_row, to_col } => {
if !from.contains_range(&r.range()) {
return Some(r.clone());
}
let mut moved = adjust_ref(r, &Edit::Move { from: *from, to_row: *to_row, to_col: *to_col })?;
// Whole-row/column references have no anchors, but their indices must move too.
moved.kind = match &r.kind {
RefKind::Rows(a, aa, b, ba) => {
let dr = *to_row as i64 - from.start.row as i64;
RefKind::Rows(shift_line(*a, false, dr, MAX_ROWS)?, *aa, shift_line(*b, false, dr, MAX_ROWS)?, *ba)
@ -214,8 +232,14 @@ fn adjust_ref(r: &Reference, edit: &Edit) -> Option<Reference> {
let dc = *to_col as i64 - from.start.col as i64;
RefKind::Cols(shift_line(*a, false, dc, MAX_COLS)?, *aa, shift_line(*b, false, dc, MAX_COLS)?, *ba)
}
_ => moved.kind,
};
Some(Reference { sheet: r.sheet.clone(), kind })
}
Edit::MoveToSheet { from, to_sheet, to_row, to_col } => {
if !from.contains_range(&r.range()) {
return Some(r.clone());
}
let mut moved = adjust_ref(r, &Edit::Move { from: *from, to_row: *to_row, to_col: *to_col })?;
moved.sheet = SheetSel::Named(to_sheet.clone());
Some(moved)
}

View File

@ -171,6 +171,8 @@ fn moves_and_renames() {
let mv = Edit::Move { from: gridcraft_core::RangeRef::parse("A1:B2").unwrap(), to_row: 9, to_col: 3 };
let e = parse("A1+B2+C3+SUM(A1:B2)").unwrap();
assert_eq!(print(&adjust(e, "S", "S", &mv)), "D10+E11+C3+SUM(D10:E11)");
let mv = Edit::Move { from: gridcraft_core::RangeRef::parse("B:C").unwrap(), to_row: 0, to_col: 4 };
assert_eq!(print(&adjust(parse("SUM(B:B)+SUM($C:$C)+D1").unwrap(), "S", "S", &mv)), "SUM(E:E)+SUM($F:$F)+D1");
let e = parse("Old!A1+'Old'!B2").unwrap();
assert_eq!(print(&rename_sheet(e, "Old", "New Name")), "'New Name'!A1+'New Name'!B2");
}

View File

@ -25,7 +25,7 @@
//! Prediction intervals use the model's h-step forecast variance (the book's Table 6.1, class 1):
//! `σ²·(1 + Σ_{j=1}^{h−1} (α + αβ·j + γ·[j mod m = 0])²)`, σ² = SSE / n, with the normal quantile.
use gridcraft_core::date::{datetime_from_serial, days_in_month_in};
use gridcraft_core::date::{datetime_from_serial, days_in_month_in, serial_from_ymd};
use gridcraft_core::{CellError, DateSystem, Value};
use crate::special::norm_inv;
@ -391,6 +391,62 @@ fn interpolate(slots: &[Option<f64>]) -> Vec<f64> {
out
}
/// A timeline's step, for continuing it past its last point (the Forecast Sheet's future rows).
#[derive(Clone, Copy, Debug)]
pub struct Timeline {
axis: Axis,
/// Position of the last time stamp.
last: i64,
}
impl Timeline {
/// The step of a set of time stamps (any order, duplicates allowed): a constant numeric step
/// (missing points allowed) or, for whole-day dates on one day of the month, calendar months.
/// `None` without at least two distinct, finite time stamps or without a constant step.
pub fn detect(times: &[f64], sys: DateSystem) -> Option<Timeline> {
if times.iter().any(|t| !t.is_finite()) {
return None;
}
let mut sorted = times.to_vec();
sorted.sort_by(|a, b| a.total_cmp(b));
sorted.dedup();
let axis = detect_axis(&sorted, sys).ok()?;
let last = axis.position(*sorted.last()?)?.round();
// Like the functions, at most 30% of the points may be missing.
if !(0.0..MAX_POINTS as f64).contains(&last) || (last + 1.0 - sorted.len() as f64) > MAX_MISSING * (last + 1.0) {
return None;
}
Some(Timeline { axis, last: last as i64 })
}
/// Steps from the last time stamp to `t` (fractional between steps, negative before it).
pub fn steps_after_last(&self, t: f64) -> Option<f64> {
let p = self.axis.position(t)? - self.last as f64;
p.is_finite().then_some(p)
}
/// The time stamp `k` steps after the last one.
pub fn after_last(&self, k: u32) -> Option<f64> {
let pos = self.last.checked_add(i64::from(k))?;
match self.axis {
Axis::Linear { t0, step } => {
let t = t0 + pos as f64 * step;
t.is_finite().then_some(t)
}
Axis::Months { sys, month0, step, day } => {
let month = month0.checked_add(pos.checked_mul(step)?)?;
let (y, m) = (month.div_euclid(12), month.rem_euclid(12) + 1);
let dim = i64::from(days_in_month_in(sys, i32::try_from(y).ok()?, u32::try_from(m).ok()?));
let d = day.map_or(dim, |d| i64::from(d).min(dim));
if y < 1900 {
return None;
}
serial_from_ymd(sys, y, m, d)
}
}
}
}
// ---------------------------------------------------------------------------------------------
// Seasonality detection
// ---------------------------------------------------------------------------------------------
@ -1002,4 +1058,19 @@ mod tests {
is_err(ets(vec![n(1e15), range(&[1.0, 2.0, 3.0]), range(&[0.0, 1.0, 1e12])]), CellError::Num);
is_err(ets(vec![n(1e308), range(&ys), range(&ts)]), CellError::Num);
}
#[test]
fn timelines_continue_past_their_last_point() {
let tl = Timeline::detect(&[3.0, 1.0, 2.0, 2.0, 5.0], DateSystem::D1900).expect("numeric step");
assert_eq!(tl.after_last(1), Some(6.0));
assert_eq!(tl.steps_after_last(8.5), Some(3.5));
// Month ends: 2024-01-31 (45322), 2024-02-29 (45351), 2024-03-31 (45382).
let tl = Timeline::detect(&[45322.0, 45351.0, 45382.0], DateSystem::D1900).expect("months");
assert_eq!(tl.after_last(1), Some(45412.0)); // 2024-04-30
assert_eq!(tl.steps_after_last(45412.0), Some(1.0));
assert!(Timeline::detect(&[1.0, 2.0, 4.5], DateSystem::D1900).is_none());
assert!(Timeline::detect(&[1.0, 2.0, 9.0], DateSystem::D1900).is_none(), "too many missing points");
assert!(Timeline::detect(&[1.0], DateSystem::D1900).is_none());
assert!(Timeline::detect(&[1.0, f64::NAN], DateSystem::D1900).is_none());
}
}

View File

@ -76,6 +76,7 @@ pub(crate) mod stat;
pub(crate) mod text;
pub(crate) mod util;
pub use ets::Timeline;
pub use stat::{aggregate_values, aggregate_values_k};
/// An evaluated argument. Ranges arrive as `Value::Array` (blank cells = `Value::Empty`) with

View File

@ -404,3 +404,116 @@ fn file_commands_without_a_file_fail() {
fails(&mut s, "execute_command", json!({"command": command, "params": {}}));
}
}
// ---------- cell references ----------
fn cmd_fails(s: &mut Server, command: &str, params: Value) -> String {
fails(s, "execute_command", json!({"command": command, "params": params}))
}
fn a1_display(s: &mut Server) -> String {
ok(s, "get_cell", json!({"cell": "A1"}))["text"].as_str().unwrap_or("").to_string()
}
#[test]
fn set_cell_invalid_ref_is_error() {
let mut s = server();
for bad in ["ZZZZ0", "B0", "A", "1", "A1:B2", "not a cell", "", "XFE1", "A1048577"] {
fails(&mut s, "set_cell", json!({"cell": bad, "input": "x"}));
}
assert_eq!(a1_display(&mut s), "");
}
#[test]
fn cell_ref_boundaries_still_work() {
let mut s = server();
ok(&mut s, "set_cell", json!({"cell": "A1", "input": "first"}));
ok(&mut s, "set_cell", json!({"cell": "$B$2", "input": "abs"}));
ok(&mut s, "set_cell", json!({"cell": "XFD1048576", "input": "last"}));
ok(&mut s, "set_cell", json!({"cell": "xfd1", "input": "lower"}));
assert_eq!(ok(&mut s, "get_cell", json!({"cell": "B2"}))["text"], "abs");
assert_eq!(ok(&mut s, "get_cell", json!({"cell": "XFD1048576"}))["text"], "last");
assert_eq!(ok(&mut s, "get_cell", json!({"cell": "XFD1"}))["text"], "lower");
assert_eq!(a1_display(&mut s), "first");
// Ranges are still ranges for the commands that take them.
ok(&mut s, "read_range", json!({"range": "A1:B2"}));
ok(&mut s, "read_range", json!({"range": "$A$1:$B$2"}));
// The active sheet's own name is an accepted prefix; an absent cell still means the selection.
ok(&mut s, "set_cell", json!({"cell": "Sheet1!C3", "input": "named"}));
assert_eq!(ok(&mut s, "get_cell", json!({"cell": "C3"}))["text"], "named");
ok(&mut s, "execute_command", json!({"command": "formulas.tracePrecedents", "params": {}}));
}
#[test]
fn bad_ref_in_command_is_error() {
let mut s = server();
ok(&mut s, "set_cell", json!({"cell": "A1", "input": "7"}));
for (cmd, key) in [("view.freezePanes", "cell"), ("formulas.tracePrecedents", "cell"), ("formulas.traceDependents", "cell"), ("cell.get", "cell")]
{
cmd_fails(&mut s, cmd, json!({ key: "B0" }));
cmd_fails(&mut s, cmd, json!({ key: "nonsense" }));
}
assert_eq!(a1_display(&mut s), "7");
}
#[test]
fn other_sheet_prefix_is_error() {
let mut s = server();
ok(&mut s, "execute_command", json!({"command": "home.insertSheet", "params": {"name": "Other"}}));
ok(&mut s, "execute_command", json!({"command": "sheet.activate", "params": {"sheet": 0}}));
fails(&mut s, "set_cell", json!({"cell": "Nope!B2", "input": "x"}));
// A command that cannot honour a sheet prefix must not drop it and use the active sheet.
cmd_fails(&mut s, "view.freezePanes", json!({"cell": "Other!B2"}));
cmd_fails(&mut s, "formulas.tracePrecedents", json!({"cell": "Other!B2"}));
ok(&mut s, "set_cell", json!({"cell": "Sheet1!B2", "input": "here"}));
assert_eq!(ok(&mut s, "get_cell", json!({"cell": "B2"}))["text"], "here");
// cell.set resolves another existing sheet itself and writes there, not to the active sheet.
ok(&mut s, "set_cell", json!({"cell": "Other!B3", "input": "there"}));
assert_eq!(ok(&mut s, "get_cell", json!({"cell": "B3"}))["text"], "");
let r = ok(&mut s, "read_range", json!({"range": "Other!B3"}));
assert!(r.to_string().contains("there"), "{r}");
}
#[test]
fn cross_sheet_cell_refs_resolve() {
let mut s = server();
ok(&mut s, "execute_command", json!({"command": "home.insertSheet", "params": {"name": "Other"}}));
ok(&mut s, "execute_command", json!({"command": "home.insertSheet", "params": {"name": "My Sheet"}}));
ok(&mut s, "execute_command", json!({"command": "sheet.activate", "params": {"sheet": 0}}));
ok(&mut s, "set_cell", json!({"cell": "Other!B3", "input": "there"}));
ok(&mut s, "set_cell", json!({"cell": "'My Sheet'!A1", "input": "quoted"}));
// Reads honour the prefix.
assert_eq!(ok(&mut s, "get_cell", json!({"cell": "Other!B3"}))["text"], "there");
assert_eq!(ok(&mut s, "get_cell", json!({"cell": "'My Sheet'!A1"}))["text"], "quoted");
assert_eq!(ok(&mut s, "get_cell", json!({"cell": "B3"}))["text"], "");
// Cell-level commands that resolve their own sheet land on it.
ok(&mut s, "execute_command", json!({"command": "review.newComment", "params": {"cell": "Other!B3", "text": "note"}}));
let c = ok(&mut s, "get_cell", json!({"cell": "Other!B3"}));
assert!(c.to_string().contains("note"), "{c}");
let c = ok(&mut s, "get_cell", json!({"cell": "B3"}));
assert!(!c.to_string().contains("note"), "{c}");
// The active sheet may be a quoted name with spaces.
ok(&mut s, "execute_command", json!({"command": "sheet.activate", "params": {"sheet": "My Sheet"}}));
ok(&mut s, "execute_command", json!({"command": "view.freezePanes", "params": {"cell": "'My Sheet'!B2"}}));
cmd_fails(&mut s, "view.freezePanes", json!({"cell": "Other!B2"}));
// A prefix naming a different sheet than the `sheet` argument is an error, not resolved in favour of either.
cmd_fails(&mut s, "cell.get", json!({"cell": "Other!B3", "sheet": "Sheet1"}));
}
#[test]
fn text_to_columns_destination_follows_its_sheet() {
let mut s = server();
ok(&mut s, "execute_command", json!({"command": "home.insertSheet", "params": {"name": "Other"}}));
ok(&mut s, "execute_command", json!({"command": "sheet.activate", "params": {"sheet": 0}}));
ok(&mut s, "set_cell", json!({"cell": "Other!A1", "input": "a,b"}));
ok(
&mut s,
"execute_command",
json!({"command": "data.textToColumns", "params": {"range": "Other!A1", "delimiters": [","], "destination": "Other!D1"}}),
);
let r = ok(&mut s, "read_range", json!({"range": "Other!D1:E1"}));
assert!(r.to_string().contains('a') && r.to_string().contains('b'), "{r}");
// An unparsable destination and a prefix that contradicts the command's sheet are errors, not A1.
cmd_fails(&mut s, "data.textToColumns", json!({"range": "Other!A1", "delimiters": [","], "destination": "D0"}));
cmd_fails(&mut s, "data.textToColumns", json!({"range": "Other!A1", "delimiters": [","], "destination": "Sheet1!D1"}));
}

View File

@ -329,7 +329,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"open_workbook",
"Open workbook",
"Open a workbook file (.xlsx, .xlsm, .xlsb, .ods, .csv, .tsv, .txt, .json) and make it active. XLSB and ODS import worksheet data and cached formula values only; check the returned warnings.",
"Open a workbook file (.xlsx, .xlsm, .xls, .xlsb, .ods, .csv, .tsv, .txt, .json) and make it active. Excel 97-2003 .xls imports values, formulas and number formats; XLSB and ODS import worksheet data and cached formula values only; check the returned warnings.",
obj(json!({"path": string("File path")}), &["path"]),
false,
),

View File

@ -49,6 +49,26 @@ pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error>
decode(&s).ok_or_else(|| serde::de::Error::custom("invalid base64"))
}
/// Base64 for optional byte blobs (VBA projects): `null`/missing stays `None`.
pub mod opt {
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(v: &Option<Vec<u8>>, s: S) -> Result<S::Ok, S::Error> {
match v {
Some(data) => s.serialize_str(&super::encode(data)),
None => s.serialize_none(),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Vec<u8>>, D::Error> {
let s = Option::<String>::deserialize(d)?;
match s {
None => Ok(None),
Some(s) => super::decode(&s).map(Some).ok_or_else(|| serde::de::Error::custom("invalid base64")),
}
}
}
#[cfg(test)]
mod tests {
#[test]

View File

@ -448,6 +448,50 @@ pub struct Shape {
pub fill: Color,
pub line: Color,
pub text: String,
/// Outline visibility and text formatting (DrawingML `a:ln`, `a:bodyPr`, `a:pPr`, `a:rPr`).
#[serde(default)]
pub format: ShapeFormat,
}
/// Horizontal alignment of a shape's text (`a:pPr algn`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum ShapeTextAlign {
#[default]
Left,
Center,
Right,
}
/// Vertical anchor of a shape's text in its box (`a:bodyPr anchor`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum ShapeTextAnchor {
#[default]
Top,
Middle,
Bottom,
}
/// How a shape's outline and text look. The text properties apply to the whole text (the first
/// run's properties when a file has several).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ShapeFormat {
/// No outline (`a:ln/a:noFill`), whatever `Shape::line` says.
pub no_line: bool,
/// Text size in points (`a:rPr sz` / 100); `None` is the default size.
pub font_size: Option<f32>,
pub bold: bool,
pub italic: bool,
/// Typeface (`a:latin typeface`); `None` is the default font.
pub font: Option<String>,
/// Text fill colour; `None` is the default (black in text boxes).
pub text_color: Option<Color>,
/// Text outline (`a:rPr/a:ln`): colour and width in points.
pub text_outline: Option<(Color, f32)>,
/// `None`: the shape's default (left for text boxes, centred for other shapes).
pub align: Option<ShapeTextAlign>,
/// `None`: the shape's default (top for text boxes, middle for other shapes).
pub anchor: Option<ShapeTextAnchor>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@ -499,6 +543,26 @@ pub struct AutoFilter {
pub criteria: Vec<(u32, FilterCriterion)>,
}
/// A slicer: an on-sheet filter widget bound to one column of a table. Clicking its value tiles
/// sets that column's `FilterCriterion::Values` in the sheet's [`AutoFilter`] and re-applies it,
/// so a slicer is just a visual front-end over the existing filter machinery.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Slicer {
pub id: u32,
/// Name of the table this slicer filters.
pub table: String,
/// Header name of the filtered column within the table.
pub column: String,
/// Caption shown in the slicer header (defaults to the column name).
pub caption: String,
pub anchor: Anchor,
/// Selected display values. `None` means every value (no filter from this slicer); an empty
/// list means nothing is selected (all rows hidden), matching Excel's "clear all buttons".
pub selected: Option<Vec<String>>,
/// Number of button columns in the layout (Excel default 1).
pub columns: u32,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Orientation {
#[default]
@ -523,10 +587,98 @@ pub struct PrintSettings {
pub headings: bool,
pub center_h: bool,
pub center_v: bool,
/// The header and footer of odd pages, and of every page unless the variants below apply.
pub header: String,
pub footer: String,
pub row_breaks: Vec<u32>,
pub col_breaks: Vec<u32>,
/// Different First Page: the sheet's first printed page uses `first_header`/`first_footer`.
#[serde(default)]
pub different_first: bool,
/// Different Odd & Even Pages: even-numbered pages use `even_header`/`even_footer`.
#[serde(default)]
pub different_odd_even: bool,
#[serde(default)]
pub first_header: String,
#[serde(default)]
pub first_footer: String,
#[serde(default)]
pub even_header: String,
#[serde(default)]
pub even_footer: String,
/// Scale with Document / Align with Page Margins (both on unless a file turned them off).
#[serde(default = "yes")]
pub hf_scale_with_doc: bool,
#[serde(default = "yes")]
pub hf_align_with_margins: bool,
}
fn yes() -> bool {
true
}
/// Which header/footer pair a printed page uses.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HfPage {
Odd,
Even,
First,
}
impl HfPage {
pub fn id(self) -> &'static str {
match self {
HfPage::Odd => "odd",
HfPage::Even => "even",
HfPage::First => "first",
}
}
pub fn parse(id: &str) -> Option<HfPage> {
match id {
"odd" | "all" => Some(HfPage::Odd),
"even" => Some(HfPage::Even),
"first" => Some(HfPage::First),
_ => None,
}
}
}
impl PrintSettings {
/// The pair used by page `number` (1-based, as `&P` prints it); `first` is the sheet's first page.
pub fn hf_page(&self, first: bool, number: usize) -> HfPage {
if self.different_first && first {
HfPage::First
} else if self.different_odd_even && number.is_multiple_of(2) {
HfPage::Even
} else {
HfPage::Odd
}
}
/// The header (`footer` false) or footer of a page kind.
pub fn hf(&self, page: HfPage, footer: bool) -> &String {
match (page, footer) {
(HfPage::Odd, false) => &self.header,
(HfPage::Odd, true) => &self.footer,
(HfPage::Even, false) => &self.even_header,
(HfPage::Even, true) => &self.even_footer,
(HfPage::First, false) => &self.first_header,
(HfPage::First, true) => &self.first_footer,
}
}
pub fn hf_mut(&mut self, page: HfPage, footer: bool) -> &mut String {
match (page, footer) {
(HfPage::Odd, false) => &mut self.header,
(HfPage::Odd, true) => &mut self.footer,
(HfPage::Even, false) => &mut self.even_header,
(HfPage::Even, true) => &mut self.even_footer,
(HfPage::First, false) => &mut self.first_header,
(HfPage::First, true) => &mut self.first_footer,
}
}
/// Any header or footer text at all (on any kind of page).
pub fn has_header_footer(&self) -> bool {
[&self.header, &self.footer, &self.first_header, &self.first_footer, &self.even_header, &self.even_footer].iter().any(|t| !t.is_empty())
}
}
impl Default for PrintSettings {
@ -548,14 +700,28 @@ impl Default for PrintSettings {
footer: String::new(),
row_breaks: vec![],
col_breaks: vec![],
different_first: false,
different_odd_even: false,
first_header: String::new(),
first_footer: String::new(),
even_header: String::new(),
even_footer: String::new(),
hf_scale_with_doc: true,
hf_align_with_margins: true,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct SheetProtection {
/// Hash of the password (our own salted FNV; never the password itself).
pub password_hash: Option<u64>,
/// Hash of the password (Excel-compatible, written to XLSX `sheetProtection`; never the
/// password itself).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password: Option<crate::PasswordHash>,
/// The internal hash older GridCraft JSON workbooks stored ([`password_hash`]); still
/// verified when unprotecting, never written for new passwords.
#[serde(rename = "password_hash", default, skip_serializing_if = "Option::is_none")]
pub legacy_password_hash: Option<u64>,
pub select_locked: bool,
pub select_unlocked: bool,
pub format_cells: bool,
@ -587,8 +753,8 @@ impl EditRange {
}
}
/// Simple non-cryptographic password hash for sheet protection (like Excel's, protection is a
/// convenience, not security).
/// The non-cryptographic password hash older GridCraft versions stored for sheet protection
/// (only verified now, see [`SheetProtection::legacy_password_hash`]).
pub fn password_hash(pw: &str) -> u64 {
let mut h: u64 = 0xcbf29ce484222325 ^ 0x5348_4545_5443;
for b in pw.bytes() {

View File

@ -9,6 +9,7 @@ pub mod cell;
mod cellmap;
pub mod features;
pub mod password;
pub mod preserved;
pub mod sheet;
pub mod store;
pub mod style;
@ -17,6 +18,7 @@ pub mod workbook;
pub use cell::{Cell, CellPicture, Formula};
pub use features::*;
pub use password::PasswordHash;
pub use preserved::{Preserved, PreservedPart, PreservedRel, PreservedXml};
pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, SheetViewMode, Visibility};
pub use store::CellStore;
pub use style::{

View File

@ -0,0 +1,70 @@
//! File content GridCraft does not model, carried through unchanged so that saving a workbook
//! never silently drops it (XLSX pass-through: custom XML, external links, connections, query
//! tables, form controls, OLE objects…).
//!
//! The data is opaque to everything except the file-format crate: the model only holds it, so a
//! sheet's kept content disappears with the sheet. It is not part of the JSON format.
use std::sync::Arc;
/// A relationship from an owning part (the package root, the workbook or a sheet, or a kept part)
/// to a kept part or to an external target.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PreservedRel {
/// The id in the source file (`rId7`). Ids of an owner GridCraft writes are reassigned on save.
pub id: String,
/// The full relationship type URI.
pub rel_type: String,
/// The original part name of the target (no leading `/`), or the raw target of an external
/// relationship. External targets are never opened.
pub target: String,
pub external: bool,
}
/// A kept package part.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PreservedPart {
/// Part name in the source file (no leading `/`).
pub name: String,
pub content_type: String,
pub data: Arc<[u8]>,
/// The part's own relationships, ids unchanged (the part's bytes refer to them).
pub rels: Vec<PreservedRel>,
}
/// A raw XML element of an owning part that refers to kept parts, re-emitted on save with its
/// relationship ids rewritten.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PreservedXml {
/// Which slot of the owner it belongs in (`externalReferences`, `oleObjects`, `controls`).
pub slot: String,
/// The element, namespace declarations it needs included.
pub xml: String,
}
/// Unmodelled content kept for one owner (the workbook, which also holds package-root content,
/// or one sheet).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Preserved {
/// Relationships of the package root (`_rels/.rels`) to kept parts (workbook owner only).
pub package_rels: Vec<PreservedRel>,
/// Relationships of the owning workbook or sheet part to kept parts.
pub rels: Vec<PreservedRel>,
/// Every kept part reachable from `package_rels` and `rels`.
pub parts: Vec<PreservedPart>,
/// Owner XML elements that refer to kept parts.
pub elements: Vec<PreservedXml>,
/// The sheet's legacy VML drawing when kept elements need its shapes (form controls, OLE
/// objects). Its own relationships point into `parts`.
pub vml: Option<PreservedPart>,
}
impl Preserved {
pub fn is_empty(&self) -> bool {
self.package_rels.is_empty() && self.rels.is_empty() && self.parts.is_empty() && self.elements.is_empty() && self.vml.is_none()
}
/// Total bytes held.
pub fn byte_len(&self) -> usize {
self.parts.iter().chain(self.vml.iter()).fold(0usize, |n, p| n.saturating_add(p.data.len()))
}
}

View File

@ -90,6 +90,9 @@ pub struct Sheet {
pub images: Vec<Image>,
pub shapes: Vec<Shape>,
pub sparklines: Vec<Sparkline>,
/// On-sheet slicer widgets (filter a table column by clicking value tiles).
#[serde(default)]
pub slicers: Vec<Slicer>,
/// PivotTables whose output lives on this sheet.
#[serde(default)]
pub pivots: Vec<PivotTable>,
@ -104,6 +107,10 @@ pub struct Sheet {
/// Normal, Page Layout or Page Break Preview.
#[serde(default)]
pub view: SheetViewMode,
/// Unmodelled file content of this sheet kept for saving (form controls, OLE objects, query
/// tables). Goes away with the sheet; not in JSON.
#[serde(skip)]
pub preserved: Option<Arc<crate::preserved::Preserved>>,
}
/// Default width of a column (points) and height of a row (points) for the default font.
@ -142,6 +149,7 @@ impl Sheet {
images: vec![],
shapes: vec![],
sparklines: vec![],
slicers: vec![],
pivots: vec![],
protection: None,
edit_ranges: vec![],
@ -149,6 +157,7 @@ impl Sheet {
view_active: CellRef::default(),
view_top_left: CellRef::default(),
view: SheetViewMode::Normal,
preserved: None,
}
}

View File

@ -67,10 +67,21 @@ pub struct Workbook {
pub props: DocProps,
/// Workbook structure protection.
pub protected_structure: bool,
/// Password of the structure protection (XLSX `workbookProtection`), never the password.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub structure_password: Option<crate::PasswordHash>,
/// Custom fill lists (Options › Custom Lists) beyond the built-in ones.
pub custom_lists: Vec<Vec<String>>,
/// Named cell styles (Cell Styles gallery): name → style.
pub cell_styles: Vec<(String, crate::style::Style)>,
/// VBA project (`xl/vbaProject.bin`) carried through unchanged. GridCraft does not run
/// macros; the blob is preserved so that saving a macro-enabled workbook never destroys the
/// macros it arrived with. base64 in JSON. `None` = the workbook has no macros.
#[serde(default, with = "crate::b64::opt")]
pub vba_project: Option<Vec<u8>>,
/// Unmodelled file content kept for saving (package root and workbook level). Not in JSON.
#[serde(skip)]
pub preserved: Option<Arc<crate::preserved::Preserved>>,
}
impl Default for Workbook {
@ -92,8 +103,11 @@ impl Workbook {
active_sheet: 0,
props: DocProps::default(),
protected_structure: false,
structure_password: None,
custom_lists: vec![],
cell_styles: vec![],
vba_project: None,
preserved: None,
}
}
pub fn sheet(&self, i: usize) -> Option<&Sheet> {
@ -216,6 +230,9 @@ impl Workbook {
for c in &s.tables {
m = m.max(c.id);
}
for c in &s.slicers {
m = m.max(c.id);
}
}
m + 1
}

View File

@ -18,8 +18,15 @@ log = { workspace = true }
image = { workspace = true }
skrifa = { workspace = true }
# Linux only, where winit reports no system theme: the desktop portal's light/dark answer (see
# `desktop_theme.rs`). Already in the tree, with these features, through egui's own
# accessibility bus; pinned to one version so nothing is built twice.
[target.'cfg(target_os = "linux")'.dependencies]
zbus = "=5.19.0"
[dev-dependencies]
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
epaint_default_fonts = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# The desktop's preferred language for the first-run interface language.

View File

@ -107,11 +107,18 @@ pub struct Backstage {
preview_page: usize,
/// Print: the preview for (document, revision, whole workbook, fit to page).
preview: Option<((u64, u64, bool, bool), Json)>,
/// Save As: the chosen file type (an extension from [`SAVE_TYPES`]).
pub save_format: &'static str,
}
/// Save As file types: (extension, label). The first is the workbook itself; the others are
/// exports that leave the workbook's own file and unsaved state alone.
pub const SAVE_TYPES: [(&str, &str); 4] =
[("xlsx", "Excel Workbook (*.xlsx)"), ("csv", "CSV UTF-8 (Comma delimited) (*.csv)"), ("html", "Web Page (*.html)"), ("pdf", "PDF (*.pdf)")];
impl Backstage {
pub fn new(page: Page) -> Backstage {
Backstage { page, props: Default::default(), print_all: false, fit_to_page: false, preview_page: 0, preview: None }
Backstage { page, props: Default::default(), print_all: false, fit_to_page: false, preview_page: 0, preview: None, save_format: "xlsx" }
}
}
@ -184,7 +191,7 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) {
Page::New => new_page(app, ui, &mut out),
Page::Open => open_page(app, ui, &mut out),
Page::Info => info(app, ui, &mut bs),
Page::SaveAs => save_as(app, ui, &mut out),
Page::SaveAs => save_as(app, ui, &mut bs, &mut out),
Page::Print => print(app, ui, &mut bs, &mut out),
Page::Export => export(app, ui, &mut out),
Page::Options => options(app, ui),
@ -422,6 +429,20 @@ fn open_page(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) {
recent_list(app, ui, out, usize::MAX);
}
/// Review › Protect Workbook and File › Info › Protect Workbook: the protect dialog (with an
/// optional password) when the structure is open; when it's locked, the password prompt if one
/// was set, else unprotect right away.
pub fn protect_workbook(app: &mut SheetApp) {
let (on, password) = app.session.active().map(|d| (d.wb.protected_structure, d.wb.structure_password.is_some())).unwrap_or((false, false));
if !on {
app.open_dialog("protectWorkbook", json!({}));
} else if password {
app.open_dialog("unprotectWorkbook", json!({}));
} else {
app.run_or_alert("review.protectWorkbook", json!({"on": false}));
}
}
fn info(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage) {
let t = Tokens::get(ui.ctx());
let lang = app.ui.language;
@ -451,7 +472,7 @@ fn info(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage) {
ui.add_space(18.0);
ui.horizontal(|ui| {
if square_button(ui, Icon::Lock, lang.tr("Protect Workbook")).clicked() {
app.run_or_alert("review.protectWorkbook", json!({"on": !protected}));
protect_workbook(app);
}
ui.add_space(12.0);
ui.vertical(|ui| {
@ -493,22 +514,40 @@ fn info(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage) {
});
}
fn save_as(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) {
fn save_as(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) {
let lang = app.ui.language;
dim(ui, lang.tr("Save the workbook under another name or in another folder."));
ui.add_space(14.0);
// Desktop: the system Save dialog. Web: the workbook downloads as .xlsx.
let label = if app.services.pick_save.is_some() || app.services.download.is_none() { "Browse…" } else { "Download (.xlsx)" };
if square_button(ui, Icon::OpenFolder, lang.tr(label)).clicked() && app.commit_edit(0, 0, false, false) {
let before = app.session.active().map(|d| (d.path.clone(), d.is_dirty()));
app.open_dialog("saveAs", json!({}));
let after = app.session.active().map(|d| (d.path.clone(), d.is_dirty()));
if after != before || app.services.pick_save.is_none() {
let fmt = bs.save_format;
let current = SAVE_TYPES.iter().find(|(ext, _)| *ext == fmt).map_or("Excel Workbook (*.xlsx)", |(_, label)| *label);
ui.horizontal(|ui| {
ui.label(lang.tr("Save as type"));
egui::ComboBox::from_id_salt("backstage_save_type").width(280.0).selected_text(lang.tr(current)).show_ui(ui, |ui| {
for (ext, label) in SAVE_TYPES {
if ui.selectable_label(fmt == ext, lang.tr(label)).clicked() {
bs.save_format = ext;
}
}
});
});
ui.add_space(14.0);
// Desktop: the system Save dialog. Web: the file downloads.
let web = app.services.pick_save.is_none() && app.services.download.is_some();
let label = if web { format!("{} (.{fmt})", lang.tr("Download")) } else { lang.tr("Browse…").to_string() };
if square_button(ui, Icon::OpenFolder, &label).clicked() && app.commit_edit(0, 0, false, false) {
if fmt == "xlsx" {
let before = app.session.active().map(|d| (d.path.clone(), d.is_dirty()));
app.open_dialog("saveAs", json!({}));
let after = app.session.active().map(|d| (d.path.clone(), d.is_dirty()));
if after != before || app.services.pick_save.is_none() {
out.close = true;
}
} else if export_as(app, fmt) {
out.close = true;
}
}
ui.add_space(18.0);
dim(ui, lang.tr("PDF, CSV and web pages are on the Export page."));
dim(ui, lang.tr("A CSV file, web page or PDF is a copy: the workbook keeps its own file."));
}
fn print(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) {
@ -724,6 +763,7 @@ pub fn export_as(app: &mut SheetApp, fmt: &str) -> bool {
}
};
}
app.store_view_state();
match app.session.run("file.saveBytes", json!({"format": fmt})) {
Ok(r) => {
let bytes = r.get("base64").and_then(Json::as_str).and_then(gridcraft_engine::io::base64_decode);

View File

@ -195,6 +195,54 @@ fn area_mesh(v: &[Pos2], col: Color32) -> egui::Mesh {
mesh
}
/// A shape's text with its formatting: size, bold/italic, typeface, colour, outline, horizontal
/// alignment and vertical anchor. Shapes other than text boxes default to white text centred in
/// the shape; text boxes to black text at the top left.
fn paint_shape_text(p: &Painter, wb: &Workbook, sp: &gridcraft_engine::model::Shape, r: Rect, z: f32) {
use gridcraft_engine::model::{ShapeTextAlign, ShapeTextAnchor};
let f = &sp.format;
let text_box = sp.kind == ShapeKind::TextBox;
let align = f.align.unwrap_or(if text_box { ShapeTextAlign::Left } else { ShapeTextAlign::Center });
let anchor = f.anchor.unwrap_or(if text_box { ShapeTextAnchor::Top } else { ShapeTextAnchor::Middle });
let default_color = if text_box { Color32::BLACK } else { Color32::WHITE };
let color = f.text_color.and_then(|c| c.resolve(&wb.theme)).map(theme::color32).unwrap_or(default_color);
// Default 14 px (as before); a set size is in points.
let size = f.font_size.map_or(14.0, |pt| pt * 96.0 / 72.0).clamp(1.0, 2000.0) * z;
let family = theme::cell_family(f.font.as_deref().unwrap_or(""), f.bold, f.italic);
let inset = 4.0;
let width = (r.width() - 2.0 * inset).max(1.0);
let mut job = egui::text::LayoutJob::simple(sp.text.clone(), FontId::new(size, family), color, width);
job.halign = match align {
ShapeTextAlign::Left => egui::Align::LEFT,
ShapeTextAlign::Center => egui::Align::Center,
ShapeTextAlign::Right => egui::Align::RIGHT,
};
let g = p.layout_job(job);
let x = match align {
ShapeTextAlign::Left => r.left() + inset,
ShapeTextAlign::Center => r.center().x,
ShapeTextAlign::Right => r.right() - inset,
};
let y = match anchor {
ShapeTextAnchor::Top => r.top() + inset,
ShapeTextAnchor::Middle => r.center().y - g.size().y / 2.0,
ShapeTextAnchor::Bottom => r.bottom() - inset - g.size().y,
};
let pos = pos2(x, y);
let clip = p.with_clip_rect(r.intersect(p.clip_rect()));
// egui has no stroked text: the outline is the text drawn in the outline colour, offset in
// eight directions by the outline width, under the filled text.
if let Some((c, w)) = f.text_outline
&& let Some(oc) = c.resolve(&wb.theme).map(theme::color32)
{
let d = (w * 96.0 / 72.0 * z / 2.0).clamp(0.5, 20.0);
for (dx, dy) in [(-1.0, -1.0), (0.0, -1.0), (1.0, -1.0), (-1.0, 0.0), (1.0, 0.0), (-1.0, 1.0), (0.0, 1.0), (1.0, 1.0)] {
clip.galley_with_override_text_color(pos + vec2(dx * d, dy * d), g.clone(), oc);
}
}
clip.galley(pos, g, color);
}
pub(crate) fn anchor_rect(geo: &Geo, sh: &Sheet, a: &Anchor) -> Rect {
let x = geo.x(sh, a.cell.col) + a.dx * geo.z;
let y = geo.y(sh, a.cell.row) + a.dy * geo.z;
@ -221,7 +269,8 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
let start = p.add(egui::Shape::Noop);
let mut label = true;
let fill = sp.fill.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::TRANSPARENT);
let line = sp.line.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::DARK_GRAY);
let line =
if sp.format.no_line { Color32::TRANSPARENT } else { sp.line.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::DARK_GRAY) };
match sp.kind {
ShapeKind::Rectangle | ShapeKind::TextBox => {
p.rect_filled(r, 0.0, fill);
@ -270,15 +319,7 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
}
}
if label && !sp.text.is_empty() && !app.editing_text_box(sp.id) {
let job = egui::text::LayoutJob::simple(
sp.text.clone(),
FontId::new(14.0 * geo.z, egui::FontFamily::Name(theme::CELL.into())),
if sp.kind == ShapeKind::TextBox { Color32::BLACK } else { Color32::WHITE },
r.width() - 8.0,
);
let g = p.layout_job(job);
let pos = if sp.kind == ShapeKind::TextBox { r.min + vec2(4.0, 4.0) } else { r.center() - g.size() / 2.0 };
p.galley(pos, g, Color32::BLACK);
paint_shape_text(p, wb, sp, r, geo.z);
}
if label && sp.kind == ShapeKind::TextBox && app.selected_chart == Some(sp.id) {
selection_frame(p, r);
@ -345,9 +386,115 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
}
}
}
paint_slicers(app, p, geo, wb, si, sh);
painted
}
/// Geometry of a slicer's parts within its screen rect.
pub struct SlicerLayout {
pub header: Rect,
/// Clear-filter button in the header.
pub clear: Rect,
/// One rect per value tile, in order.
pub tiles: Vec<Rect>,
}
pub fn slicer_layout(rect: Rect, z: f32, n: usize, columns: u32) -> SlicerLayout {
let header_h = 26.0 * z;
let pad = 6.0 * z;
let gap = 3.0 * z;
let tile_h = 22.0 * z;
let header = Rect::from_min_size(rect.min, vec2(rect.width(), header_h));
let cb = 16.0 * z;
let clear = Rect::from_min_size(pos2(header.right() - pad - cb, header.top() + (header_h - cb) / 2.0), vec2(cb, cb));
let cols = columns.max(1) as f32;
let inner_left = rect.left() + pad;
let inner_top = header.bottom() + pad;
let inner_w = (rect.width() - 2.0 * pad).max(1.0);
let tile_w = ((inner_w - (cols - 1.0) * gap) / cols).max(1.0);
let mut tiles = Vec::with_capacity(n);
for i in 0..n {
let col = (i as f32) % cols;
let row = (i as f32 / cols).floor();
let x = inner_left + col * (tile_w + gap);
let y = inner_top + row * (tile_h + gap);
tiles.push(Rect::from_min_size(pos2(x, y), vec2(tile_w, tile_h)));
}
SlicerLayout { header, clear, tiles }
}
fn paint_slicers(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet) {
let tk = theme::Tokens::get(p.ctx());
for sl in &sh.slicers {
let r = anchor_rect(geo, sh, &sl.anchor);
if !r.intersects(p.clip_rect()) {
continue;
}
let items = gridcraft_engine::cmd::slicer::slicer_items(wb, si, sl);
let lay = slicer_layout(r, geo.z, items.len(), sl.columns);
let radius = 4.0 * geo.z;
// Card + soft shadow.
p.rect_filled(r.translate(vec2(0.0, 1.5)), radius, Color32::from_black_alpha(18));
p.rect_filled(r, radius, tk.window);
p.rect_stroke(r, radius, Stroke::new(1.0, tk.separator), StrokeKind::Inside);
// Header.
p.rect_filled(lay.header, radius, tk.accent_soft);
p.rect_filled(Rect::from_min_max(pos2(lay.header.left(), lay.header.bottom() - radius), lay.header.max), 0.0, tk.accent_soft);
let cap = if sl.caption.is_empty() { sl.column.clone() } else { sl.caption.clone() };
p.text(lay.header.left_center() + vec2(8.0 * geo.z, 0.0), Align2::LEFT_CENTER, cap, theme::ui_bold(12.5 * geo.z), tk.accent_dark);
// Clear-filter button (tinted when a filter is active).
let filtered = sl.selected.is_some();
if let Some(icon) = crate::icons::from_name("filter") {
crate::icons::paint(p, lay.clear, icon, if filtered { tk.accent } else { tk.text_dim });
}
// Value tiles.
for ((val, on), tr) in items.iter().zip(&lay.tiles) {
if !r.contains(tr.left_top()) {
continue; // overflowed the card; skip (no scroll yet)
}
let (fill, fg) = if *on { (tk.accent, Color32::WHITE) } else { (tk.hover, tk.text_dim) };
p.rect_filled(*tr, 3.0 * geo.z, fill);
let cp = p.with_clip_rect(tr.intersect(p.clip_rect()));
cp.text(tr.left_center() + vec2(6.0 * geo.z, 0.0), Align2::LEFT_CENTER, val, theme::ui_font(12.0 * geo.z), fg);
}
if app.selected_chart == Some(sl.id) {
selection_frame(p, r);
}
}
}
/// What a click inside a slicer hit.
pub enum SlicerAction {
Toggle(String),
Clear,
}
/// A click on a slicer's value tile or clear button, if any. A click elsewhere in a slicer returns
/// `None` so the caller can treat it as a select/drag on the object.
pub fn slicer_click(geo: &Geo, sh: &Sheet, wb: &Workbook, si: usize, p: Pos2) -> Option<(u32, SlicerAction)> {
if !geo.cells.contains(p) {
return None;
}
for sl in sh.slicers.iter().rev() {
let r = anchor_rect(geo, sh, &sl.anchor);
if !r.contains(p) {
continue;
}
let items = gridcraft_engine::cmd::slicer::slicer_items(wb, si, sl);
let lay = slicer_layout(r, geo.z, items.len(), sl.columns);
if lay.clear.contains(p) {
return Some((sl.id, SlicerAction::Clear));
}
for ((val, _on), tr) in items.iter().zip(&lay.tiles) {
if r.contains(tr.left_top()) && tr.contains(p) {
return Some((sl.id, SlicerAction::Toggle(val.clone())));
}
}
return None; // inside this (topmost) slicer, but on its header/background → select/drag
}
None
}
fn selection_frame(p: &Painter, r: Rect) {
p.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_gray(120)), StrokeKind::Outside);
for c in [r.left_top(), r.center_top(), r.right_top(), r.left_center(), r.right_center(), r.left_bottom(), r.center_bottom(), r.right_bottom()] {
@ -360,6 +507,12 @@ fn selection_frame(p: &Painter, r: Rect) {
/// The topmost object under `p`: (kind, id, rect).
pub fn hit(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(&'static str, u32, Rect)> {
let _ = app;
for sl in sh.slicers.iter().rev() {
let r = anchor_rect(geo, sh, &sl.anchor);
if r.contains(p) && geo.cells.contains(p) {
return Some(("slicer", sl.id, r));
}
}
for ch in sh.charts.iter().rev() {
let r = anchor_rect(geo, sh, &ch.anchor);
if r.expand(4.0).contains(p) && geo.cells.contains(p) {

View File

@ -0,0 +1,132 @@
//! The desktop's own light/dark choice, for an interface setting that follows it.
//!
//! winit reports no system theme on Linux — `ActiveEventLoop::system_theme` is `None` on every
//! desktop there — so "Use system setting" has to ask the desktop itself. The XDG Desktop Portal's
//! Settings interface answers on GNOME, KDE and XFCE alike, over X11 and Wayland alike.
use std::sync::mpsc::{Receiver, TryRecvError, channel};
use std::time::Duration;
/// How often [`DesktopTheme::start`] re-reads the desktop's answer. The portal also publishes a
/// change signal, but subscribing needs a connection kept alive for the life of every window; one
/// round trip every few seconds is cheap and reacts soon enough after a theme switch.
const POLL: Duration = Duration::from_secs(4);
/// Whether the desktop asks applications to draw dark. `None` when it says nothing, and when
/// there is no session bus or no portal to ask (a bare X server, a container), so the caller
/// keeps whatever it chose itself.
pub fn prefers_dark() -> Option<bool> {
read()
}
/// Watches the desktop and reports its choice when it changes. [`prefers_dark`] covers the
/// answer at start-up; this covers the user switching theme while the app runs.
#[derive(Default)]
pub struct DesktopTheme {
/// `None` when no watcher thread could be started; the one-shot read still works.
changes: Option<Receiver<Option<bool>>>,
}
impl DesktopTheme {
/// Start watching. Nothing runs before this is called, and calling it once is enough. The
/// first answer is sent straight away, so the caller does not have to ask for it separately.
pub fn start() -> Self {
let (tx, rx) = channel();
let started = std::thread::Builder::new().name("gridcraft-desktop-theme".into()).spawn(move || {
let mut last = read();
if tx.send(last).is_err() {
return;
}
loop {
std::thread::sleep(POLL);
let now = read();
if now != last {
last = now;
if tx.send(now).is_err() {
return;
}
}
}
});
Self { changes: started.ok().map(|_| rx) }
}
/// The desktop's new answer if it changed since the last call, to be polled once a frame.
/// Several changes in one frame collapse to the newest, and a desktop that stops answering
/// leaves the last answer in place.
pub fn take_change(&mut self) -> Option<bool> {
let Some(changes) = &self.changes else { return None };
let mut newest = None;
loop {
match changes.try_recv() {
Ok(changed) => newest = changed,
Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => return newest,
}
}
}
}
/// Ask the portal once. D-Bus calls block, so this belongs off the UI thread: the watcher's
/// thread is its only caller.
#[cfg(target_os = "linux")]
fn read() -> Option<bool> {
const PORTAL: &str = "org.freedesktop.portal.Desktop";
const SETTINGS: &str = "org.freedesktop.portal.Settings";
const SCHEMAS: [(&str, &str); 2] = [("org.gnome.desktop.interface", "color-scheme"), ("org.freedesktop.interface.color-scheme", "color-scheme")];
let connection = zbus::blocking::Connection::session().ok()?;
for (schema, key) in SCHEMAS {
let Ok(reply) = connection.call_method(Some(PORTAL), "/org/freedesktop/portal/desktop", Some(SETTINGS), "ReadOne", &(schema, key)) else {
continue;
};
let Ok((answer,)) = reply.body().deserialize::<(zbus::zvariant::OwnedValue,)>() else { continue };
if let Ok(scheme) = String::try_from(&*answer) {
return parse(&scheme);
}
}
None
}
/// The portal's `color-scheme` answer. `default` means the desktop states no preference, so
/// neither does the application.
#[cfg(target_os = "linux")]
fn parse(scheme: &str) -> Option<bool> {
match scheme {
"prefer-dark" => Some(true),
"prefer-light" => Some(false),
_ => None,
}
}
/// macOS and Windows answer through winit, which the caller already has.
#[cfg(not(target_os = "linux"))]
fn read() -> Option<bool> {
None
}
#[cfg(test)]
mod tests {
use super::*;
/// The portal answers with one of three strings; only the two preferences are a choice.
#[test]
#[cfg(target_os = "linux")]
fn the_portal_answer_reads_as_a_choice_or_as_none() {
assert_eq!(parse("prefer-dark"), Some(true));
assert_eq!(parse("prefer-light"), Some(false));
assert_eq!(parse("default"), None);
assert_eq!(parse(""), None);
}
/// Off Linux the caller already has winit's answer, so this adds nothing.
#[test]
#[cfg(not(target_os = "linux"))]
fn other_platforms_answer_through_winit() {
assert_eq!(prefers_dark(), None);
}
/// A watcher that was never started is inert rather than a panic.
#[test]
fn a_watcher_without_a_thread_is_inert() {
assert_eq!(DesktopTheme::default().take_change(), None);
}
}

View File

@ -219,6 +219,23 @@ impl Dialog {
json!({"action": "add", "range": sel}),
)
}
"protectWorkbook" => Dialog::form(
"protectWorkbook",
"Protect Structure and Windows",
"review.protectWorkbook",
vec![
Password { key: "password", label: "Password (optional):" },
Note("Protect workbook for: Structure (sheets can't be added, moved, renamed, hidden or deleted).".into()),
],
json!({"on": true}),
),
"unprotectWorkbook" => Dialog::form(
"unprotectWorkbook",
"Unprotect Workbook",
"review.protectWorkbook",
vec![Password { key: "password", label: "Password:" }],
json!({"on": false}),
),
"unprotectSheet" => Dialog::form(
"unprotectSheet",
"Unprotect Sheet",
@ -373,6 +390,40 @@ impl Dialog {
json!({"header": true}),
),
"goalSeek" => Dialog::custom("goalSeek", "Goal Seek", json!({"set": active, "to": "0", "changing": ""})),
"forecastSheet" => Dialog::form(
"forecastSheet",
"Create Forecast Worksheet",
"data.forecastSheet",
vec![
Text { key: "timeline", label: "Timeline range (blank: first column of the selection):" },
Text { key: "values", label: "Values range (blank: second column of the selection):" },
Text { key: "end", label: "Forecast end (date or number; blank: a third of the history):" },
Check { key: "confidenceInterval", label: "Confidence interval" },
Text { key: "confidence", label: "Confidence level (%):" },
Text { key: "seasonality", label: "Seasonality (auto, 0 for none, or a season length):" },
Choice {
key: "fillMissing",
label: "Fill missing points using",
options: vec![("interpolate", "Interpolation"), ("zeros", "Zeros")],
},
Choice {
key: "aggregate",
label: "Aggregate duplicates using",
options: vec![
("average", "Average"),
("count", "Count"),
("counta", "CountA"),
("max", "Max"),
("median", "Median"),
("min", "Min"),
("sum", "Sum"),
],
},
Check { key: "statistics", label: "Include forecast statistics" },
Choice { key: "chart", label: "Chart", options: vec![("line", "Line chart"), ("column", "Column chart")] },
],
json!({"confidenceInterval": true, "confidence": "95", "seasonality": "auto", "fillMissing": "interpolate", "aggregate": "average", "chart": "line"}),
),
"analysisToolPak" => {
// Opened with a known `tool`, it goes straight to that tool's form.
let tool = params.get("tool").and_then(Json::as_str).unwrap_or("");
@ -660,6 +711,11 @@ impl Dialog {
)
}
"journal" => Dialog::custom("journal", "Action Journal", json!({})),
"macros" => {
let mut d = Dialog::custom("macros", "Macros", json!({}));
d.result = app.session.run("view.macros", json!({})).ok();
d
}
"statistics" => {
let mut d = Dialog::custom("statistics", "Workbook Statistics", json!({}));
d.result = app.session.run("review.workbookStatistics", json!({})).ok();
@ -812,7 +868,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
let width = match d.name.as_str() {
"formatCells" => 560.0,
"about" => 660.0,
"insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" => 520.0,
"insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" | "macros" => 520.0,
"recommendedCharts" => 600.0,
"analysisToolPak" => 420.0,
"solver" => 460.0,
@ -859,6 +915,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
}
});
}
"macros" => macros(ui, &mut d),
"statistics" | "accessibility" | "errorChecking" | "evaluateFormula" | "comments" => {
let r = if d.name == "comments" {
app.session.active().and_then(|doc| doc.wb.active().map(|s| json!(s.comments.iter().map(|(c, m)| json!({"cell": c.a1(), "author": m.author, "text": m.text})).collect::<Vec<_>>())))
@ -1251,6 +1308,34 @@ fn about(ui: &mut egui::Ui, d: &mut Dialog) {
}
}
/// The Macros dialog: lists GridCraft's own recorded scripts and (if the file carries one) the
/// preserved VBA project. Mirrors Excel's Developer ▸ Macros list, but is honest that VBA is
/// preserved and not executed.
fn macros(ui: &mut egui::Ui, d: &mut Dialog) {
ui.label("Automation in this workbook. GridCraft runs its own scripts; a file's VBA project is preserved but not executed.");
ui.add_space(6.0);
let items: Vec<Json> = d.result.as_ref().and_then(Json::as_array).cloned().unwrap_or_default();
if items.is_empty() {
ui.weak("This workbook has no scripts or macros.");
return;
}
egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
for it in &items {
let name = it.get("name").and_then(Json::as_str).unwrap_or("(unnamed)");
let is_vba = it.get("kind").and_then(Json::as_str) == Some("vba");
ui.horizontal(|ui| {
ui.label(egui::RichText::new(name).strong());
if is_vba {
ui.weak("— VBA project (preserved, not run)");
} else {
let steps = it.get("steps").and_then(Json::as_u64).unwrap_or(0);
ui.weak(format!("— {steps} step{}", if steps == 1 { "" } else { "s" }));
}
});
}
});
}
fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) {
ui.horizontal(|ui| {
for tab in ["Number", "Alignment", "Font", "Border", "Fill", "Protection"] {
@ -1346,8 +1431,8 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm:
}
"Font" => {
egui::ComboBox::from_label("Font").selected_text(st.font.name.clone()).show_ui(ui, |ui| {
for f in crate::ribbon::FONTS {
ui.selectable_value(&mut st.font.name, f.to_string(), *f);
for f in crate::fonts::families() {
ui.selectable_value(&mut st.font.name, f.clone(), f.clone());
}
});
ui.add(egui::Slider::new(&mut st.font.size, 6.0..=72.0).text("Size"));

423
crates/ui-egui/src/fonts.rs Normal file
View File

@ -0,0 +1,423 @@
//! The fonts installed on the machine, so the font list can offer them and cells can render with
//! them (#4).
//!
//! egui has no font discovery of its own and panics when asked for a family that was never bound
//! to font data. So we do both halves: scan the platform's font folders for family names (a read
//! of each font file's `name` table — damaged or unreadable files yield nothing, never a panic),
//! and bind the files a workbook actually uses into the families
//! [`theme::font_definitions`](crate::theme::font_definitions) assembles.
//!
//! Native only: on wasm there are no system fonts, every function here reports "nothing", and the
//! font list is the standard [`crate::ribbon::FONTS`] set.
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::OnceLock;
/// A font file is skipped by the scan past this size: a catalog only needs each face's name, and a
/// damaged or enormous file must not turn startup into a long read.
#[cfg(not(target_arch = "wasm32"))]
const MAX_FONT_FILE: u64 = 64 << 20;
/// One face of one installed font file.
#[derive(Clone, Debug)]
pub struct Face {
/// Typographic family name, e.g. `"Segoe UI"`.
pub family: String,
/// The file the face lives in.
pub path: PathBuf,
/// Face index within the file (collections hold several, e.g. a family's Regular and Bold).
pub index: u32,
/// Weight implied by the face's style name (400 = regular).
pub weight: f32,
/// Whether the style name reads as italic or oblique.
pub italic: bool,
}
/// The font folders of the platform (the system's and the user's). Empty on wasm.
pub fn system_font_dirs() -> Vec<PathBuf> {
#[cfg(target_arch = "wasm32")]
{
Vec::new()
}
#[cfg(not(target_arch = "wasm32"))]
{
let mut dirs: Vec<PathBuf> = Vec::new();
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from);
if cfg!(target_os = "macos") {
dirs.extend(["/System/Library/Fonts", "/System/Library/Fonts/Supplemental", "/Library/Fonts"].map(Into::into));
if let Some(h) = &home {
dirs.push(h.join("Library/Fonts"));
}
} else if cfg!(windows) {
let root = std::env::var_os("WINDIR").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("C:\\Windows"));
dirs.push(root.join("Fonts"));
if let Some(l) = std::env::var_os("LOCALAPPDATA") {
dirs.push(PathBuf::from(l).join("Microsoft\\Windows\\Fonts"));
}
} else {
dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts", "/usr/share/fonts/TTF"].map(Into::into));
if let Some(h) = &home {
dirs.push(h.join(".fonts"));
dirs.push(h.join(".local/share/fonts"));
}
}
dirs
}
}
/// Weight implied by a style name (`"Bold"` → 700, `"Light"` → 300).
#[cfg(not(target_arch = "wasm32"))]
fn style_weight(style: &str) -> f32 {
let s: String = style.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect();
const TABLE: &[(&str, f32)] = &[
("hairline", 100.0),
("thin", 100.0),
("extralight", 200.0),
("ultralight", 200.0),
("light", 300.0),
("medium", 500.0),
("demibold", 600.0),
("semibold", 600.0),
("extrabold", 800.0),
("ultrabold", 800.0),
("bold", 700.0),
("black", 900.0),
("heavy", 900.0),
];
TABLE.iter().find(|(k, _)| s.contains(k)).map(|(_, w)| *w).unwrap_or(400.0)
}
/// Whether a style name reads as italic or oblique.
#[cfg(not(target_arch = "wasm32"))]
fn style_italic(style: &str) -> bool {
let s: String = style.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect();
s.contains("italic") || s.contains("oblique")
}
/// Every face in `data` as `(index, family, style)`. Damaged data yields nothing, never a panic.
#[cfg(not(target_arch = "wasm32"))]
fn file_faces(data: &[u8]) -> Vec<(u32, String, String)> {
use skrifa::raw::FileRef;
use skrifa::{MetadataProvider, string::StringId};
let count = match FileRef::new(data) {
Ok(FileRef::Font(_)) => 1,
Ok(FileRef::Collection(c)) => c.len(),
Err(_) => return Vec::new(),
};
let name = |f: &skrifa::FontRef<'_>, ids: &[StringId]| -> Option<String> {
ids.iter().find_map(|id| f.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()))
};
(0..count)
.filter_map(|i| {
let f = skrifa::FontRef::from_index(data, i).ok()?;
let family = name(&f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME])?;
let style = name(&f, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into());
Some((i, family, style))
})
.collect()
}
/// Catalog every face under `dirs`, recursing into subfolders. A folder reached twice (through a
/// symlink) is read once, so a link back to a parent ends the walk.
#[cfg(not(target_arch = "wasm32"))]
pub fn scan_dirs(dirs: &[PathBuf]) -> Vec<Face> {
let mut out: Vec<Face> = Vec::new();
let mut stack: Vec<PathBuf> = dirs.to_vec();
let mut seen_dirs: HashSet<PathBuf> = HashSet::new();
while let Some(dir) = stack.pop() {
let key = std::fs::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
if !seen_dirs.insert(key) {
continue;
}
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
continue;
}
let is_font =
path.extension().and_then(|e| e.to_str()).is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc"));
if !is_font || std::fs::metadata(&path).map(|m| m.len() > MAX_FONT_FILE).unwrap_or(true) {
continue;
}
let Ok(data) = std::fs::read(&path) else { continue };
for (index, family, style) in file_faces(&data) {
out.push(Face { family, path: path.clone(), index, weight: style_weight(&style), italic: style_italic(&style) });
}
}
}
out
}
#[cfg(target_arch = "wasm32")]
pub fn scan_dirs(_dirs: &[PathBuf]) -> Vec<Face> {
Vec::new()
}
static CATALOG: OnceLock<Vec<Face>> = OnceLock::new();
/// The catalog of installed faces, scanned on first use. Blocks until the scan finishes.
pub fn catalog() -> &'static [Face] {
CATALOG.get_or_init(|| scan_dirs(&system_font_dirs()))
}
/// The catalog if the scan has finished, else `None` — never blocks, for use while painting.
fn catalog_now() -> Option<&'static [Face]> {
CATALOG.get().map(Vec::as_slice)
}
/// Start cataloging the installed fonts on a background thread, so the first frame doesn't wait for
/// it. A no-op on wasm and after the scan has started.
pub fn start_scan() {
static STARTED: OnceLock<()> = OnceLock::new();
STARTED.get_or_init(|| {
#[cfg(not(target_arch = "wasm32"))]
if CATALOG.get().is_none() {
// A failed spawn leaves the scan to the first blocking lookup.
let _ = std::thread::Builder::new().name("font-scan".into()).spawn(|| {
catalog();
});
}
});
}
/// Whether the background scan has finished.
pub fn scan_done() -> bool {
CATALOG.get().is_some()
}
/// The faces of one installed family, regular-weight first (the order roles pick from).
pub fn faces_of(family: &str) -> Vec<Face> {
let Some(cat) = catalog_now() else { return Vec::new() };
let mut v: Vec<Face> = cat.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).cloned().collect();
v.sort_by(|a, b| a.weight.total_cmp(&b.weight).then(a.italic.cmp(&b.italic)).then(a.index.cmp(&b.index)));
v
}
/// The subset of `names` that is installed, sorted and deduped. These are the families whose files
/// get bound for a workbook.
pub fn installed_among(names: &[String]) -> Vec<String> {
let Some(cat) = catalog_now() else { return Vec::new() };
let mut out: Vec<String> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
for wanted in names {
if !seen.insert(wanted.to_lowercase()) {
continue;
}
if let Some(f) = cat.iter().find(|f| f.family.eq_ignore_ascii_case(wanted)) {
out.push(f.family.clone());
}
}
out.sort_by_key(|a| a.to_lowercase());
out
}
/// The font list a combo box shows: the standard [`crate::ribbon::FONTS`] names first (what Excel
/// offers, and what we substitute), then every other installed family A–Z. Never blocks: until the
/// background scan finishes it returns just the standard names.
pub fn families() -> Vec<String> {
let mut out: Vec<String> = crate::ribbon::FONTS.iter().map(|s| s.to_string()).collect();
let Some(cat) = catalog_now() else { return out };
let mut seen: HashSet<String> = out.iter().map(|s| s.to_lowercase()).collect();
let mut installed: Vec<String> = cat.iter().map(|f| f.family.clone()).collect();
installed.sort_by_key(|a| a.to_lowercase());
installed.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
for f in installed {
if seen.insert(f.to_lowercase()) {
out.push(f);
}
}
out
}
/// The egui family name for one role of one family: `fnt:{family}:{b|n}{i|n}`.
pub fn role_key(family: &str, bold: bool, italic: bool) -> String {
format!("fnt:{family}:{}{}", if bold { 'b' } else { 'n' }, if italic { 'i' } else { 'n' })
}
/// The face to use for a role, or `None` when the family has no faces. Missing bold/italic faces
/// fall back to the family's regular face, so every bound role has data behind it.
fn pick_face(faces: &[Face], bold: bool, italic: bool) -> Option<&Face> {
let regular = || faces.iter().find(|f| !f.italic && (f.weight - 400.0).abs() < 1.0);
let exact = |wanted_bold: bool, wanted_italic: bool| {
faces.iter().find(|f| f.italic == wanted_italic && if wanted_bold { f.weight >= 600.0 } else { f.weight < 600.0 })
};
(match (bold, italic) {
(true, true) => exact(true, true).or_else(|| exact(true, false)).or_else(|| exact(false, true)),
(true, false) => exact(true, false),
(false, true) => exact(false, true),
(false, false) => None,
})
.or_else(regular)
.or_else(|| faces.first())
}
/// A stable key for a face's font data, unique per file/index within the process.
fn face_key(family: &str, face: &Face) -> String {
let file = face.path.file_name().and_then(|s| s.to_str()).unwrap_or("font");
format!("fnt-src:{family}:{file}:{}", face.index)
}
/// Bind one installed family's faces into `fonts`, one egui family per cell role, and return the
/// role keys that are now bound. A role with no exact face chains to the family's regular face, so
/// no role is ever left pointing at missing data (egui panics on that). A family with no faces
/// binds nothing.
///
/// Each role's chain ends with egui's default proportional fonts, so glyphs the installed font
/// lacks (emoji, a stray script) still render.
pub fn bind_faces(fonts: &mut egui::FontDefinitions, family: &str, faces: &[Face]) -> Vec<String> {
if faces.is_empty() {
return Vec::new();
}
let base: Vec<String> = fonts.families.get(&egui::FontFamily::Proportional).cloned().unwrap_or_default();
let mut bound: Vec<String> = Vec::new();
for (bold, italic) in [(false, false), (true, false), (false, true), (true, true)] {
let Some(primary) = pick_face(faces, bold, italic) else { continue };
let mut chain: Vec<String> = Vec::new();
for face in [Some(primary), pick_face(faces, false, false)] {
let Some(face) = face else { continue };
let key = face_key(family, face);
if chain.contains(&key) {
continue;
}
let Ok(bytes) = std::fs::read(&face.path) else { continue };
let mut fd = egui::FontData::from_owned(bytes);
fd.index = face.index;
fonts.font_data.entry(key.clone()).or_insert_with(|| std::sync::Arc::new(fd));
chain.push(key);
}
if chain.is_empty() {
continue;
}
chain.extend(base.iter().cloned());
let role = role_key(family, bold, italic);
fonts.families.insert(egui::FontFamily::Name(role.clone().into()), chain);
bound.push(role);
}
bound
}
/// Bind every installed face of `family` (see [`bind_faces`]).
pub fn bind_family(fonts: &mut egui::FontDefinitions, family: &str) -> Vec<String> {
bind_faces(fonts, family, &faces_of(family))
}
/// The role families currently bound to font data. `cell_family` consults this, so it never names a
/// family egui hasn't been given.
static BOUND: OnceLock<std::sync::RwLock<HashSet<String>>> = OnceLock::new();
fn bound() -> &'static std::sync::RwLock<HashSet<String>> {
BOUND.get_or_init(|| std::sync::RwLock::new(HashSet::new()))
}
/// Record the role families a freshly built [`egui::FontDefinitions`] bound.
pub fn set_bound(keys: impl IntoIterator<Item = String>) {
*bound().write().unwrap_or_else(|e| e.into_inner()) = keys.into_iter().collect();
}
/// Is this exact role family bound to font data?
pub fn is_bound(role: &str) -> bool {
bound().read().unwrap_or_else(|e| e.into_inner()).contains(role)
}
/// Serializes tests that mutate the process-wide bound-family registry.
#[cfg(test)]
pub(crate) static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn style_names_map_to_weight_and_slant() {
assert_eq!(style_weight("Bold"), 700.0);
assert_eq!(style_weight("SemiBold Italic"), 600.0);
assert_eq!(style_weight("Regular"), 400.0);
assert_eq!(style_weight("Light Oblique"), 300.0);
assert!(style_italic("Bold Italic") && style_italic("Oblique"));
assert!(!style_italic("Bold"));
// The weight table is checked in order, so "extralight" wins over "light".
assert_eq!(style_weight("ExtraLight"), 200.0);
}
#[test]
fn role_keys_are_stable_and_distinct() {
assert_eq!(role_key("Segoe UI", false, false), "fnt:Segoe UI:nn");
assert_eq!(role_key("Segoe UI", true, false), "fnt:Segoe UI:bn");
assert_eq!(role_key("Segoe UI", false, true), "fnt:Segoe UI:ni");
assert_eq!(role_key("Segoe UI", true, true), "fnt:Segoe UI:bi");
}
#[test]
fn nothing_is_bound_until_font_definitions_says_so() {
let _g = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
// A role key is only safe to name after font_definitions bound it.
let k = role_key("Some Installed Font", true, false);
assert!(!is_bound(&k));
set_bound([k.clone()]);
assert!(is_bound(&k));
set_bound([]);
assert!(!is_bound(&k));
}
#[test]
fn damaged_or_non_font_files_contribute_nothing() {
assert!(file_faces(b"ttcf\x00\x01\x00\x00\xff\xff\xff\xff").is_empty());
assert!(file_faces(b"not a font at all").is_empty());
assert!(file_faces(&[]).is_empty());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn the_scan_catalogs_fonts_and_reads_each_folder_once() {
let dir = std::env::temp_dir().join(format!("gridcraft-fonts-scan-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("Sub")).expect("temp dir");
std::fs::write(dir.join("Ubuntu-Light.ttf"), epaint_default_fonts::UBUNTU_LIGHT).expect("write");
std::fs::write(dir.join("damaged.ttf"), b"ttcf\x00\x01\x00\x00\xff\xff\xff\xff").expect("write");
std::fs::write(dir.join("readme.txt"), b"not a font").expect("write");
// The same folder listed twice (once through `Sub/..`) is read once.
let faces = scan_dirs(&[dir.clone(), dir.join("Sub/..")]);
let families: Vec<&str> = faces.iter().map(|f| f.family.as_str()).collect();
assert_eq!(families, ["Ubuntu"], "one face, family read from the name table");
assert_eq!(faces[0].weight, 300.0, "“Light” reads as weight 300");
assert!(!faces[0].italic);
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn a_link_back_to_a_parent_folder_ends_the_scan() {
let dir = std::env::temp_dir().join(format!("gridcraft-fonts-loop-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("Sub")).expect("temp dir");
std::fs::write(dir.join("Sub/Ubuntu-Light.ttf"), epaint_default_fonts::UBUNTU_LIGHT).expect("write");
std::os::unix::fs::symlink(&dir, dir.join("Sub/loop")).expect("symlink");
assert_eq!(scan_dirs(std::slice::from_ref(&dir)).len(), 1, "the loop doesn't multiply faces");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn binding_a_family_binds_all_four_roles_and_never_stores_missing_data() {
let dir = std::env::temp_dir().join(format!("gridcraft-fonts-bind-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temp dir");
let path = dir.join("Ubuntu-Light.ttf");
std::fs::write(&path, epaint_default_fonts::UBUNTU_LIGHT).expect("write");
let face = Face { family: "Ubuntu".into(), path, index: 0, weight: 300.0, italic: false };
let mut fonts = egui::FontDefinitions::default();
let bound = bind_faces(&mut fonts, "Ubuntu", std::slice::from_ref(&face));
assert_eq!(bound.len(), 4, "regular, bold, italic and bold-italic roles");
for key in &bound {
let chain = fonts.families.get(&egui::FontFamily::Name(key.as_str().into())).expect("role family");
assert!(chain.iter().all(|k| fonts.font_data.contains_key(k)), "every chained key has data");
}
// A family with no faces binds nothing and never touches the map.
assert!(bind_faces(&mut fonts, "No Such Font", &[]).is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
}

View File

@ -78,7 +78,7 @@ fn active_input(app: &SheetApp) -> String {
// A legacy array formula shows in braces (`{=A1:A2*2}`), as `Cell::bar_text` does.
if f.array.is_some() { format!("{{{shown}}}") } else { shown }
} else {
c.input_text()
gridcraft_engine::display::edit_text(&d.wb, sh, a)
};
}
// A spilled cell shows the anchor's formula greyed out in Excel; we show it plainly.

View File

@ -8,7 +8,7 @@ use std::sync::Arc;
use egui::{Align2, Color32, CursorIcon, FontId, Painter, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use gridcraft_engine::cf::{CfCache, CfLook};
use gridcraft_engine::core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, col_to_letters};
use gridcraft_engine::model::{BorderStyle, Color, HAlign, LineIndex, PatternType, Sheet, Style, Underline, VAlign, Workbook};
use gridcraft_engine::model::{BorderLine, BorderStyle, Color, HAlign, LineIndex, PatternType, Sheet, Style, Underline, VAlign, Workbook};
use serde_json::json;
use crate::SheetApp;
@ -27,6 +27,11 @@ pub enum Drag {
Select,
Rows(u32),
Cols(u32),
/// Reordering one contiguous whole-column selection by its header.
MoveCols {
source: RangeRef,
before: Option<u32>,
},
ResizeCol {
col: u32,
start: f32,
@ -95,6 +100,8 @@ pub struct GridState {
pub paper: Option<crate::paper_view::PaperCache>,
/// Header or footer being edited in place (Page Layout view).
pub hf_edit: Option<crate::paper_view::HfEdit>,
/// Insert › Header & Footer: start editing the first page's header once Page Layout view is up.
pub hf_start: bool,
/// The grid geometry of the last frame (hit-testing for agents and tests).
pub geo: Option<Geo>,
}
@ -116,6 +123,12 @@ pub struct Geo {
pub z: f32,
pub header_w: f32,
pub header_h: f32,
/// The outline bands (grouped rows / columns): left of the row headers and above the column
/// headers; 0 without an outline (see `outline`).
pub band_w: f32,
pub band_h: f32,
pub row_outline: Arc<crate::outline::Axis>,
pub col_outline: Arc<crate::outline::Axis>,
pub fr: u32,
pub fc: u32,
pub frozen_w: f32,
@ -146,8 +159,46 @@ impl Geo {
let (fr, fc) = sh.freeze.unwrap_or((0, 0));
let frozen_w = (ci.start(fc) as f32) * z;
let frozen_h = (ri.start(fr) as f32) * z;
let cells = Rect::from_min_max(pos2(rect.left() + header_w, rect.top() + header_h), rect.max);
Geo { rect, cells, z, header_w, header_h, fr, fc, frozen_w, frozen_h, scroll, rtl: sh.right_to_left, ri, ci, paper: None }
let row_outline = Arc::new(crate::outline::Axis::new(&sh.rows));
let col_outline = Arc::new(crate::outline::Axis::new(&sh.cols));
let mut band_w = crate::outline::band(row_outline.levels, zoom);
let mut band_h = crate::outline::band(col_outline.levels, zoom);
// Without headings the level buttons still need a corner to sit in.
if row_outline.levels > 0 && header_h == 0.0 {
band_h = band_h.max(crate::outline::lane(zoom) + 3.0);
}
if col_outline.levels > 0 && header_w == 0.0 {
band_w = band_w.max(crate::outline::lane(zoom) + 3.0);
}
let cells = Rect::from_min_max(pos2(rect.left() + band_w + header_w, rect.top() + band_h + header_h), rect.max);
Geo {
rect,
cells,
z,
header_w,
header_h,
band_w,
band_h,
row_outline,
col_outline,
fr,
fc,
frozen_w,
frozen_h,
scroll,
rtl: sh.right_to_left,
ri,
ci,
paper: None,
}
}
/// Left edge of the row headers (right of the outline band).
pub fn head_left(&self) -> f32 {
self.cells.left() - self.header_w
}
/// Top edge of the column headers (below the outline band).
pub fn head_top(&self) -> f32 {
self.cells.top() - self.header_h
}
/// Page Layout view geometry: `rect` excludes the rulers; cells are drawn at the print scale
/// on pages laid out by `grid`; frozen panes don't apply (as in Excel).
@ -553,6 +604,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
paint_headers(&painter, &geo, sh, &sel, &t, &quads);
paint_freeze_handles(&painter, &geo, sh, &app.grid.drag, &t);
}
crate::outline::paint(&painter, &geo, sh, &t);
if geo.rtl {
let mirror_to = painter.add(egui::Shape::Noop);
mirror_painted(ui.ctx(), painter.layer_id(), mirror_from.0..mirror_to.0, &geo, &objects);
@ -929,10 +981,17 @@ pub(crate) fn paint_quadrant(
continue;
}
let first_line = text.lines().next().unwrap_or("").to_string();
// Shrink to fit: too-wide content is drawn in a smaller font so it fits the cell (or the
// merged area): it neither spills, nor is clipped, nor turns into ####.
let small = (st.align.shrink && !rotated && st.align.h != HAlign::Fill && fill_char.is_none())
.then(|| shrink_font(p, &first_line, &font, avail_w - 2.0 * pad - indent))
.flatten();
let shrunk = small.is_some();
let font = small.unwrap_or(font);
let mut galley = p.layout_no_wrap(first_line.clone(), font.clone(), color);
let mut w = galley.size().x;
// Numbers and dates never overflow: they show #### when too narrow.
if (numeric || matches!(v, Value::Number(_))) && w > avail_w - 2.0 * pad && st.align.h != HAlign::Fill {
if !shrunk && (numeric || matches!(v, Value::Number(_))) && w > avail_w - 2.0 * pad && st.align.h != HAlign::Fill {
let n = ((avail_w - 2.0 * pad) / char_w).floor().max(1.0) as usize;
galley = p.layout_no_wrap("#".repeat(n), font.clone(), color);
w = galley.size().x;
@ -1032,12 +1091,19 @@ pub(crate) fn paint_quadrant(
// Borders (over fills and text).
for (c, rect, lk, _) in &looks {
let b = &lk.style.borders;
let edges = [
(b.top, [rect.left_top(), rect.right_top()]),
(b.bottom, [rect.left_bottom(), rect.right_bottom()]),
(b.left, [rect.left_top(), rect.left_bottom()]),
(b.right, [rect.right_top(), rect.right_bottom()]),
];
let merge = merges.iter().find(|m| m.start == *c);
let edges = match merge {
// A merged area's outline lives on the cells along each of its edges (Excel writes the
// right edge on the last column's cells and the bottom edge on the last row's), so each
// edge is drawn cell by cell from the cell it belongs to.
Some(m) => merged_edges(wb, si, sh, geo, *m, *rect, (r0, r1), (c0, c1), cf),
None => vec![
(b.top, [rect.left_top(), rect.right_top()]),
(b.bottom, [rect.left_bottom(), rect.right_bottom()]),
(b.left, [rect.left_top(), rect.left_bottom()]),
(b.right, [rect.right_top(), rect.right_bottom()]),
],
};
for (line, pts) in edges {
if line.is_none() {
continue;
@ -1108,6 +1174,60 @@ pub fn filter_button_rect(geo: &Geo, sh: &Sheet, c: CellRef) -> Rect {
Rect::from_min_size(pos2(rect.right() - s - 2.0, rect.bottom() - s - 2.0), vec2(s, s))
}
/// The outline segments of merged area `m` drawn at `rect`: the top and left edges come from the
/// first row's and first column's cells, the bottom and right edges from the last row's and last
/// column's cells, one segment per visible cell (limited to the visible `rows` × `cols`).
#[allow(clippy::too_many_arguments)]
fn merged_edges(
wb: &Workbook,
si: usize,
sh: &Sheet,
geo: &Geo,
m: RangeRef,
rect: Rect,
rows: (u32, u32),
cols: (u32, u32),
cf: &mut CfCache,
) -> Vec<(BorderLine, [Pos2; 2])> {
let mut out = Vec::new();
let (cs, ce) = (m.start.col.max(cols.0), m.end.col.min(cols.1));
for col in cs..=ce {
if sh.col_width(col) == 0.0 {
continue;
}
let (x0, x1) = (geo.x(sh, col), geo.x(sh, col.saturating_add(1)));
let top = look(wb, si, sh, CellRef::new(m.start.row, col), cf).0.style.borders.top;
let bottom = look(wb, si, sh, CellRef::new(m.end.row, col), cf).0.style.borders.bottom;
out.push((top, [pos2(x0, rect.top()), pos2(x1, rect.top())]));
out.push((bottom, [pos2(x0, rect.bottom()), pos2(x1, rect.bottom())]));
}
let (rs, re) = (m.start.row.max(rows.0), m.end.row.min(rows.1));
for row in rs..=re {
if sh.row_height(row) == 0.0 {
continue;
}
let (y0, y1) = (geo.y(sh, row), geo.y(sh, row.saturating_add(1)));
let left = look(wb, si, sh, CellRef::new(row, m.start.col), cf).0.style.borders.left;
let right = look(wb, si, sh, CellRef::new(row, m.end.col), cf).0.style.borders.right;
out.push((left, [pos2(rect.left(), y0), pos2(rect.left(), y1)]));
out.push((right, [pos2(rect.right(), y0), pos2(rect.right(), y1)]));
}
out
}
/// The smaller font that makes `text` fit in `room` points of width (Shrink to fit), or `None`
/// when it already fits at its own size.
fn shrink_font(p: &Painter, text: &str, font: &FontId, room: f32) -> Option<FontId> {
let natural = p.layout_no_wrap(text.to_string(), font.clone(), Color32::PLACEHOLDER).size().x;
let room = room.max(1.0);
if natural.is_nan() || natural <= room {
return None;
}
// Glyph advances don't scale exactly linearly (rounding), so leave a little slack.
let size = (font.size * room / natural * 0.97).max(1.0);
Some(FontId::new(size, font.family.clone()))
}
pub(crate) fn paint_border(p: &Painter, pts: [Pos2; 2], style: BorderStyle, col: Color32) {
let w = style.width();
let (a, b) = (pts[0], pts[1]);
@ -1299,6 +1419,10 @@ pub(crate) fn paint_selection(
p.rect_stroke(rr, 0.0, Stroke::new(2.0, Color32::from_gray(110)), StrokeKind::Middle);
}
}
if let crate::grid::Drag::MoveCols { before: Some(before), .. } = &app.grid.drag {
let x = geo.x(sh, *before);
p.line_segment([pos2(x, geo.rect.top()), pos2(x, geo.rect.bottom())], Stroke::new(3.0, t.accent));
}
// Marching ants around the copied range.
if let Some(clip) = &app.session.clipboard
&& app.session.active().is_some_and(|d| d.uid == clip.doc_uid && d.wb.active_sheet == clip.sheet)
@ -1324,7 +1448,8 @@ pub(crate) fn paint_headers(
quads: &[((u32, u32), (u32, u32), Rect)],
) {
let font = theme::ui_font((11.5 * (geo.z / DISPLAY_SCALE).max(0.75)).min(16.0));
let r = geo.rect;
// The headers' own area: inside the outline bands.
let r = Rect::from_min_max(pos2(geo.head_left(), geo.head_top()), geo.rect.max);
let top = Rect::from_min_max(pos2(geo.cells.left(), r.top()), pos2(r.right(), geo.cells.top()));
let left = Rect::from_min_max(pos2(r.left(), geo.cells.top()), pos2(geo.cells.left(), r.bottom()));
p.rect_filled(top, 0.0, t.header_bg);
@ -1412,9 +1537,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
// Hit-testing works in logical coordinates (see `Geo::rtl`).
let pos = pointer.interact_pos().or(pointer.hover_pos()).map(|p| geo.mirror_pos(p));
let in_cells = pos.is_some_and(|p| geo.in_cells(p));
let in_col_header = pos.is_some_and(|p| p.y < geo.cells.top() && p.y >= geo.rect.top() && p.x >= geo.cells.left());
let in_row_header = pos.is_some_and(|p| p.x < geo.cells.left() && p.x >= geo.rect.left() && p.y >= geo.cells.top());
let in_corner = pos.is_some_and(|p| p.x < geo.cells.left() && p.y < geo.cells.top() && geo.rect.contains(p));
let in_col_header = pos.is_some_and(|p| p.y < geo.cells.top() && p.y >= geo.head_top() && p.x >= geo.cells.left());
let in_row_header = pos.is_some_and(|p| p.x < geo.cells.left() && p.x >= geo.head_left() && p.y >= geo.cells.top());
let in_corner = pos.is_some_and(|p| p.x < geo.cells.left() && p.y < geo.cells.top() && p.x >= geo.head_left() && p.y >= geo.head_top());
let sel = app.session.active().map(|d| d.selection.clone()).unwrap_or_default();
let cur_rect = geo.range_rect(sh, sel.current());
let handle = Rect::from_center_size(cur_rect.right_bottom(), vec2(9.0, 9.0));
@ -1464,7 +1589,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
} else if in_cells {
ctx.set_cursor_icon(CursorIcon::Cell);
} else if in_col_header {
ctx.set_cursor_icon(CursorIcon::Default);
let col = geo.col_at(sh, p.x);
let selected = sel.ranges.len() == 1 && sel.current().is_full_cols() && sel.current().contains(CellRef::new(0, col));
ctx.set_cursor_icon(if selected { CursorIcon::Move } else { CursorIcon::Default });
}
app.grid.hover_cell = in_cells.then(|| geo.cell_at(sh, p));
}
@ -1514,7 +1641,25 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
return;
}
}
// Objects (charts, pictures, shapes) take clicks first.
// A plain click on a slicer value tile toggles it (Ctrl/Cmd for multi-select); the clear
// button resets it. Runs before the generic object select/drag so tiles stay clickable.
if primary_clicked
&& !resp.drag_started()
&& let Some(p) = pos
&& let Some((id, act)) = crate::chartview::slicer_click(geo, sh, wb, wb.active_sheet, p)
{
app.selected_chart = Some(id);
match act {
crate::chartview::SlicerAction::Toggle(value) => {
let _ = app.run("slicer.toggle", json!({"slicer": id, "value": value, "multi": mods.command || mods.ctrl}));
}
crate::chartview::SlicerAction::Clear => {
let _ = app.run("slicer.clear", json!({"slicer": id}));
}
}
return;
}
// Objects (charts, pictures, shapes, slicers) take clicks first.
if resp.drag_started() || primary_clicked {
// A drag is recognised only after the pointer has moved, so grab the object where the press began.
let at = if resp.drag_started() { pointer.press_origin().or(pos) } else { pos };
@ -1575,6 +1720,14 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
if resp.drag_started() || (primary_clicked && app.grid.drag == Drag::None) {
// Where the press began (a drag is only recognised after the pointer has moved).
let Some(p) = pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) else { return };
// The outline band's − / + and level buttons act on release, like buttons.
if let Some(action) = crate::outline::hit(geo, sh, p) {
if primary_clicked {
let (command, params) = action.command();
app.run_or_alert(command, params);
}
return;
}
// Freeze handles sit on the header edges (and the corner's edges when nothing is frozen).
if freeze_cols.contains(p) || freeze_rows.contains(p) {
app.grid.drag = Drag::Freeze { cols: freeze_cols.contains(p), to: None };
@ -1590,6 +1743,15 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
return;
}
let c = geo.col_at(sh, p.x);
if resp.drag_started()
&& !mods.any()
&& sel.ranges.len() == 1
&& sel.current().is_full_cols()
&& sel.current().contains(CellRef::new(0, c))
{
app.grid.drag = Drag::MoveCols { source: sel.current(), before: None };
return;
}
let range = if mods.shift { RangeRef::cols(sel.anchor.col.min(c), sel.anchor.col.max(c)) } else { RangeRef::cols(c, c) };
select_ranges(app, range, mods.command, CellRef::new(geo.row_at(sh, geo.cells.top() + 1.0), c));
app.grid.drag = Drag::Cols(if mods.shift { sel.anchor.col } else { c });
@ -1673,9 +1835,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
d.selection.anchor = sel.anchor;
}
} else if mods.command {
let mut ranges: Vec<String> = sel.ranges.iter().map(|r| r.a1()).collect();
ranges.push(c.a1());
let _ = app.session.run("selection.set", json!({"range": ranges.join(","), "active": c.a1()}));
select_ranges(app, RangeRef::cell(c), true, c);
} else {
let _ = app.session.run("selection.set", json!({"cell": c.a1()}));
if primary_clicked && wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" {
@ -1743,6 +1903,14 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
app.session.run("selection.set", json!({"range": RangeRef::cols(anchor.min(c), anchor.max(c)).a1(), "active": sel.active.a1()}));
scroll_by(app, vec2(auto.x, 0.0));
}
Drag::MoveCols { source, .. } => {
let col = geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0));
let midpoint = geo.x(sh, col) + sh.col_width(col) * geo.z * 0.5;
let before = (col + u32::from(p.x >= midpoint)).min(MAX_COLS - 1);
let before = (!(source.start.col..=source.end.col + 1).contains(&before)).then_some(before);
app.grid.drag = Drag::MoveCols { source, before };
scroll_by(app, vec2(auto.x, 0.0));
}
Drag::ResizeCol { col, start, orig } => {
let w = (orig + (p.x - start) / geo.z).max(0.0);
app.toast =
@ -1841,6 +2009,11 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
let _ = app.run("edit.paste", json!({"at": to.a1()}));
}
}
Drag::MoveCols { source, before: Some(before) } => {
if let Err(e) = app.run("sheet.moveColumns", json!({"cols": source.a1(), "before": before})) {
app.message = Some(("GridCraft".into(), crate::clean_error(&e)));
}
}
_ => {}
}
app.grid.drag_select = false;
@ -1982,8 +2155,8 @@ fn on_border(r: Rect, p: Pos2) -> bool {
pub fn freeze_handles(geo: &Geo) -> (Rect, Rect) {
let x = geo.cells.left() + geo.frozen_w;
let y = geo.cells.top() + geo.frozen_h;
let cols = Rect::from_min_max(pos2(x - 3.0, geo.rect.top()), pos2(x + 3.0, geo.rect.top() + geo.header_h * 0.4));
let rows = Rect::from_min_max(pos2(geo.rect.left(), y - 3.0), pos2(geo.rect.left() + geo.header_w * 0.4, y + 3.0));
let cols = Rect::from_min_max(pos2(x - 3.0, geo.head_top()), pos2(x + 3.0, geo.head_top() + geo.header_h * 0.4));
let rows = Rect::from_min_max(pos2(geo.head_left(), y - 3.0), pos2(geo.head_left() + geo.header_w * 0.4, y + 3.0));
(cols, rows)
}
@ -2021,10 +2194,10 @@ fn paint_freeze_handles(p: &Painter, geo: &Geo, sh: &Sheet, drag: &Drag, t: &Tok
let stroke = Stroke::new(2.0, t.accent);
if cols {
let x = geo.cells.left() + sh.col_left(to) as f32 * geo.z;
p.line_segment([pos2(x, geo.rect.top()), pos2(x, geo.cells.bottom())], stroke);
p.line_segment([pos2(x, geo.head_top()), pos2(x, geo.cells.bottom())], stroke);
} else {
let y = geo.cells.top() + sh.row_top(to) as f32 * geo.z;
p.line_segment([pos2(geo.rect.left(), y), pos2(geo.cells.right(), y)], stroke);
p.line_segment([pos2(geo.head_left(), y), pos2(geo.cells.right(), y)], stroke);
}
}
}
@ -2065,6 +2238,7 @@ fn object_anchor(sh: &Sheet, kind: &str, id: u32) -> Option<gridcraft_engine::mo
match kind {
"chart" => sh.charts.iter().find(|c| c.id == id).map(|c| c.anchor),
"image" => sh.images.iter().find(|c| c.id == id).map(|c| c.anchor),
"slicer" => sh.slicers.iter().find(|c| c.id == id).map(|c| c.anchor),
_ => sh.shapes.iter().find(|c| c.id == id).map(|c| c.anchor),
}
}
@ -2097,11 +2271,29 @@ pub fn follow_link(app: &mut SheetApp, target: &str) {
}
}
/// The areas after a click: with `add` (Ctrl) the clicked area is added, or removed when it is already
/// selected (unless it is the only one, so the selection never ends up empty).
fn toggled_ranges(mut ranges: Vec<String>, clicked: String, add: bool) -> Vec<String> {
if !add {
return vec![clicked];
}
if ranges.len() > 1 && ranges.contains(&clicked) {
ranges.retain(|x| *x != clicked);
} else if !ranges.contains(&clicked) {
ranges.push(clicked);
}
ranges
}
fn select_ranges(app: &mut SheetApp, r: RangeRef, add: bool, active: CellRef) {
let mut ranges: Vec<String> =
let current: Vec<String> =
if add { app.session.active().map(|d| d.selection.ranges.iter().map(|x| x.a1()).collect()).unwrap_or_default() } else { vec![] };
ranges.push(r.a1());
let _ = app.session.run("selection.set", json!({"range": ranges.join(","), "active": active.a1()}));
let clicked = r.a1();
let ranges = toggled_ranges(current, clicked.clone(), add);
// A removed area takes the active cell with it; `selection.set` then puts it at the start of the last area.
let params =
if ranges.contains(&clicked) { json!({"range": ranges.join(","), "active": active.a1()}) } else { json!({"range": ranges.join(",")}) };
let _ = app.session.run("selection.set", params);
}
/// Grid keyboard handling when no text field has focus.
@ -2124,6 +2316,11 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
m.set_focus_lock_filter(resp.id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true })
});
let events = ctx.input(|i| i.events.clone());
// A shortcut key that also types a character (Shift+Space sends Text(" ")) must not start an edit.
let shortcut_pressed = events.iter().any(|e| {
matches!(e, egui::Event::Key { pressed: true, modifiers: m, key, .. }
if !m.alt && (m.ctrl || m.command || (m.shift && *key == egui::Key::Space)))
});
let page = ((geo.cells.height() / geo.z) / sh.default_row_height).floor().max(1.0) as i64;
for ev in events {
match ev {
@ -2134,6 +2331,7 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
app.copy_to_clipboard(ctx, "edit.cut");
}
egui::Event::Paste(text) => app.paste_external(text),
egui::Event::Text(_) if shortcut_pressed => {}
egui::Event::Text(text) if text.chars().all(|c| !c.is_control()) && !text.is_empty() => {
app.begin_edit(Some(text), false);
if let Some(ed) = app.editor.as_mut() {
@ -2202,8 +2400,9 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
app.session.format_painter = None;
}
Key::Space if !cmd && !shift && is_checkbox(sh, app) => app.run_or_alert("cell.toggleCheckbox", json!({})),
Key::Space if cmd && !shift => app.run_or_alert("selection.column", json!({})),
Key::Space if shift && !cmd => app.run_or_alert("selection.row", json!({})),
Key::F8 if m.alt => {
app.open_dialog("macros", json!({}));
}
Key::F9 => app.run_or_alert("formulas.calculateNow", json!({})),
Key::F4 if !cmd => {}
_ if cmd => crate::ribbon::shortcut(app, key, m),
@ -2267,7 +2466,12 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
let Some(sh) = wb.sheet(si) else { return };
let _ = geo;
// Tables get an autofilter on demand.
let table_range = sh.tables.first().filter(|t| col >= t.range.start.col && col <= t.range.end.col).map(|t| t.range);
let table_range = sh
.tables
.iter()
.find(|t| t.range.start.row <= at.y as u32 || true)
.filter(|t| col >= t.range.start.col && col <= t.range.end.col)
.map(|t| t.range);
if sh.autofilter.is_none()
&& let Some(r) = table_range
{
@ -2892,6 +3096,16 @@ mod pane_tests {
assert!(!range_in_pane(sel, corner.0, corner.1));
}
#[test]
fn ctrl_click_on_a_selected_header_deselects_it() {
let s = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
assert_eq!(toggled_ranges(s(&["A:A", "C:C"]), "C:C".into(), true), s(&["A:A"]));
assert_eq!(toggled_ranges(s(&["A:A"]), "C:C".into(), true), s(&["A:A", "C:C"]));
assert_eq!(toggled_ranges(s(&["A:A"]), "A:A".into(), true), s(&["A:A"]));
assert_eq!(toggled_ranges(s(&["A1", "B2", "3:3"]), "B2".into(), true), s(&["A1", "3:3"]));
assert_eq!(toggled_ranges(s(&["A:A", "C:C"]), "C:C".into(), false), s(&["C:C"]));
}
#[test]
fn selection_across_the_freeze_line_is_drawn_in_both_panes() {
let sel = rng(5, 0, 7, 6);

View File

@ -334,6 +334,8 @@ const JAPANESE: &[(&str, &str)] = &[
("Clear Validation", "入力規則のクリア"),
("Group", "グループ化"),
("Ungroup", "グループ解除"),
("Show Detail", "詳細データの表示"),
("Hide Detail", "詳細を表示しない"),
("Subtotal", "小計"),
("Data\nAnalysis", "データ分析"),
("Data Analysis", "データ分析"),
@ -342,6 +344,7 @@ const JAPANESE: &[(&str, &str)] = &[
("Data Table…", "データ テーブル…"),
("Scenario Manager…", "シナリオ マネージャー…"),
("Goal Seek…", "ゴール シーク…"),
("Forecast\nSheet", "予測シート"),
("Spelling", "スペルチェック"),
("Check\nAccessibility", "アクセシビリティ チェック"),
("Threaded Comments", "スレッド化されたコメント"),
@ -483,6 +486,8 @@ const JAPANESE: &[(&str, &str)] = &[
("File Name", "ファイル名"),
("Sheet Name", "シート名"),
("Go to Header", "ヘッダーに移動"),
("Different First Page", "先頭ページのみ別指定"),
("Different Odd & Even Pages", "奇数/偶数ページ別指定"),
("Go to Footer", "フッターに移動"),
("Ruler", "ルーラー"),
// File › Backstage view.
@ -521,9 +526,17 @@ const JAPANESE: &[(&str, &str)] = &[
("Keywords", "キーワード"),
("Description", "説明"),
("No workbook is open.", "ブックが開かれていません。"),
("Save as type", "ファイルの種類"),
("Excel Workbook (*.xlsx)", "Excel ブック (*.xlsx)"),
("CSV UTF-8 (Comma delimited) (*.csv)", "CSV UTF-8 (コンマ区切り) (*.csv)"),
("Web Page (*.html)", "Web ページ (*.html)"),
("PDF (*.pdf)", "PDF ファイル (*.pdf)"),
("Download", "ダウンロード"),
(
"A CSV file, web page or PDF is a copy: the workbook keeps its own file.",
"CSV ファイル、Web ページ、PDF はコピーです。ブックは元のファイルのままです。",
),
("Save the workbook under another name or in another folder.", "ブックを別の名前または別のフォルダーに保存します。"),
("Download (.xlsx)", "ダウンロード (.xlsx)"),
("PDF, CSV and web pages are on the Export page.", "PDF、CSV、Web ページは [エクスポート] ページにあります。"),
("Print Active Sheets", "作業中のシートを印刷"),
("Print Entire Workbook", "ブック全体を印刷"),
("Fit Sheet on One Page", "シートを 1 ページに印刷"),

View File

@ -164,6 +164,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Clear Validation" => "Vymazat ověření",
"Group" => "Seskupit",
"Ungroup" => "Zrušit seskupení",
"Show Detail" => "Zobrazit podrobnosti",
"Hide Detail" => "Skrýt podrobnosti",
"Subtotal" => "Mezisoučet",
"What-If\nAnalysis" => "Citlivostní\nanalýza",
"Data Table…" => "Tabulka dat…",
@ -184,6 +186,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Gridlines" => "Mřížka",
"Headings" => "Záhlaví",
"Zoom" => "Lupa",
"Forecast\nSheet" => "List\nprognózy",
"Add footer" => "Přidat zápatí",
"Add header" => "Přidat záhlaví",
"Click to add data" => "Kliknutím přidáte data",
@ -196,6 +199,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"File\nPath" => "Cesta\nk souboru",
"File Path" => "Cesta k souboru",
"Footer" => "Zápatí",
"Different First Page" => "Jiná první stránka",
"Different Odd & Even Pages" => "Jiné liché a sudé stránky",
"Go to Footer" => "Přejít na zápatí",
"Go to Header" => "Přejít na záhlaví",
"Go to\nFooter" => "Přejít\nna zápatí",
@ -270,9 +275,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Keywords" => "Klíčová slova",
"Description" => "Popis",
"No workbook is open." => "Není otevřený žádný sešit.",
"Save as type" => "Typ souboru",
"Excel Workbook (*.xlsx)" => "Sešit Excelu (*.xlsx)",
"CSV UTF-8 (Comma delimited) (*.csv)" => "CSV UTF-8 (oddělený čárkami) (*.csv)",
"Web Page (*.html)" => "Webová stránka (*.html)",
"PDF (*.pdf)" => "Soubor PDF (*.pdf)",
"Download" => "Stáhnout",
"A CSV file, web page or PDF is a copy: the workbook keeps its own file." => {
"Soubor CSV, webová stránka nebo PDF je kopie: sešit si ponechá svůj vlastní soubor."
}
"Save the workbook under another name or in another folder." => "Uloží sešit pod jiným názvem nebo do jiné složky.",
"Download (.xlsx)" => "Stáhnout (.xlsx)",
"PDF, CSV and web pages are on the Export page." => "PDF, CSV a webové stránky najdete na stránce Exportovat.",
"Print Active Sheets" => "Vytisknout aktivní listy",
"Print Entire Workbook" => "Vytisknout celý sešit",
"Fit Sheet on One Page" => "Přizpůsobit list na jednu stránku",

View File

@ -164,6 +164,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Clear Validation" => "Datenüberprüfung löschen",
"Group" => "Gruppieren",
"Ungroup" => "Gruppierung aufheben",
"Show Detail" => "Details anzeigen",
"Hide Detail" => "Details ausblenden",
"Subtotal" => "Teilergebnis",
"What-If\nAnalysis" => "Was-wäre-wenn-\nAnalyse",
"Data Table…" => "Datentabelle…",
@ -184,6 +186,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Gridlines" => "Gitternetzlinien",
"Headings" => "Überschriften",
"Zoom" => "Zoom",
"Forecast\nSheet" => "Prognose-\nblatt",
"Add footer" => "Fußzeile hinzufügen",
"Add header" => "Kopfzeile hinzufügen",
"Click to add data" => "Klicken, um Daten hinzuzufügen",
@ -196,6 +199,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"File\nPath" => "Datei-\npfad",
"File Path" => "Dateipfad",
"Footer" => "Fußzeile",
"Different First Page" => "Erste Seite anders",
"Different Odd & Even Pages" => "Gerade und ungerade Seiten anders",
"Go to Footer" => "Zur Fußzeile wechseln",
"Go to Header" => "Zur Kopfzeile wechseln",
"Go to\nFooter" => "Zur\nFußzeile",
@ -268,11 +273,18 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Keywords" => "Stichwörter",
"Description" => "Beschreibung",
"No workbook is open." => "Es ist keine Arbeitsmappe geöffnet.",
"Save as type" => "Dateityp",
"Excel Workbook (*.xlsx)" => "Excel-Arbeitsmappe (*.xlsx)",
"CSV UTF-8 (Comma delimited) (*.csv)" => "CSV UTF-8 (durch Trennzeichen getrennt) (*.csv)",
"Web Page (*.html)" => "Webseite (*.html)",
"PDF (*.pdf)" => "PDF-Datei (*.pdf)",
"Download" => "Herunterladen",
"A CSV file, web page or PDF is a copy: the workbook keeps its own file." => {
"Eine CSV-Datei, Webseite oder PDF ist eine Kopie: Die Arbeitsmappe behält ihre eigene Datei."
}
"Save the workbook under another name or in another folder." => {
"Speichert die Arbeitsmappe unter einem anderen Namen oder in einem anderen Ordner."
}
"Download (.xlsx)" => "Herunterladen (.xlsx)",
"PDF, CSV and web pages are on the Export page." => "PDF, CSV und Webseiten finden Sie auf der Seite „Exportieren“.",
"Print Active Sheets" => "Aktive Blätter drucken",
"Print Entire Workbook" => "Gesamte Arbeitsmappe drucken",
"Fit Sheet on One Page" => "Blatt auf einer Seite darstellen",

View File

@ -165,6 +165,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Clear Validation" => "Borrar círculos de validación",
"Group" => "Agrupar",
"Ungroup" => "Desagrupar",
"Show Detail" => "Mostrar detalle",
"Hide Detail" => "Ocultar detalle",
"Subtotal" => "Subtotal",
"What-If\nAnalysis" => "Análisis de\nhipótesis",
"Data Table…" => "Tabla de datos…",
@ -185,6 +187,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Gridlines" => "Líneas de cuadrícula",
"Headings" => "Encabezados",
"Zoom" => "Zoom",
"Forecast\nSheet" => "Hoja de\npronóstico",
"Add footer" => "Agregar pie de página",
"Add header" => "Agregar encabezado",
"Click to add data" => "Haga clic para agregar datos",
@ -197,6 +200,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"File\nPath" => "Ruta de\narchivo",
"File Path" => "Ruta de archivo",
"Footer" => "Pie de página",
"Different First Page" => "Primera página diferente",
"Different Odd & Even Pages" => "Páginas pares e impares diferentes",
"Go to Footer" => "Ir al pie de página",
"Go to Header" => "Ir al encabezado",
"Go to\nFooter" => "Ir al pie\nde página",
@ -269,9 +274,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Keywords" => "Palabras clave",
"Description" => "Descripción",
"No workbook is open." => "No hay ningún libro abierto.",
"Save as type" => "Tipo",
"Excel Workbook (*.xlsx)" => "Libro de Excel (*.xlsx)",
"CSV UTF-8 (Comma delimited) (*.csv)" => "CSV UTF-8 (delimitado por comas) (*.csv)",
"Web Page (*.html)" => "Página web (*.html)",
"PDF (*.pdf)" => "Archivo PDF (*.pdf)",
"Download" => "Descargar",
"A CSV file, web page or PDF is a copy: the workbook keeps its own file." => {
"Un archivo CSV, una página web o un PDF son una copia: el libro conserva su propio archivo."
}
"Save the workbook under another name or in another folder." => "Guarda el libro con otro nombre o en otra carpeta.",
"Download (.xlsx)" => "Descargar (.xlsx)",
"PDF, CSV and web pages are on the Export page." => "PDF, CSV y páginas web están en la página Exportar.",
"Print Active Sheets" => "Imprimir hojas activas",
"Print Entire Workbook" => "Imprimir todo el libro",
"Fit Sheet on One Page" => "Ajustar hoja en una página",

View File

@ -171,6 +171,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Clear Validation" => "유효성 검사 지우기",
"Group" => "그룹",
"Ungroup" => "그룹 해제",
"Show Detail" => "세부 정보 표시",
"Hide Detail" => "세부 정보 숨기기",
"Subtotal" => "부분합",
"Data\nAnalysis" => "데이터 분석",
"Data Analysis" => "데이터 분석",
@ -179,6 +181,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Data Table…" => "데이터 표…",
"Scenario Manager…" => "시나리오 관리자…",
"Goal Seek…" => "목표값 찾기…",
"Forecast\nSheet" => "예측 시트",
"Spelling" => "맞춤법 검사",
"Check\nAccessibility" => "접근성\n검사",
"Threaded Comments" => "스레드 댓글",
@ -320,6 +323,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"File Name" => "파일 이름",
"Sheet Name" => "시트 이름",
"Go to Header" => "머리글로 이동",
"Different First Page" => "첫 페이지를 다르게 지정",
"Different Odd & Even Pages" => "짝수와 홀수 페이지를 다르게 지정",
"Go to Footer" => "바닥글로 이동",
"Ruler" => "눈금자",
// File › Backstage view.
@ -356,9 +361,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Keywords" => "키워드",
"Description" => "설명",
"No workbook is open." => "열려 있는 통합 문서가 없습니다.",
"Save as type" => "파일 형식",
"Excel Workbook (*.xlsx)" => "Excel 통합 문서 (*.xlsx)",
"CSV UTF-8 (Comma delimited) (*.csv)" => "CSV UTF-8(쉼표로 분리) (*.csv)",
"Web Page (*.html)" => "웹 페이지 (*.html)",
"PDF (*.pdf)" => "PDF 파일 (*.pdf)",
"Download" => "다운로드",
"A CSV file, web page or PDF is a copy: the workbook keeps its own file." => {
"CSV 파일, 웹 페이지 또는 PDF는 사본이며 통합 문서는 원래 파일을 유지합니다."
}
"Save the workbook under another name or in another folder." => "통합 문서를 다른 이름 또는 다른 폴더에 저장합니다.",
"Download (.xlsx)" => "다운로드(.xlsx)",
"PDF, CSV and web pages are on the Export page." => "PDF, CSV 및 웹 페이지는 내보내기 페이지에 있습니다.",
"Print Active Sheets" => "활성 시트 인쇄",
"Print Entire Workbook" => "전체 통합 문서 인쇄",
"Fit Sheet on One Page" => "한 페이지에 시트 맞추기",

View File

@ -172,6 +172,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Clear Validation" => "Limpar Validação",
"Group" => "Agrupar",
"Ungroup" => "Desagrupar",
"Show Detail" => "Mostrar Detalhe",
"Hide Detail" => "Ocultar Detalhe",
"Subtotal" => "Subtotal",
"Data\nAnalysis" => "Análise de Dados",
"Data Analysis" => "Análise de Dados",
@ -180,6 +182,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Data Table…" => "Tabela de Dados…",
"Scenario Manager…" => "Gerenciador de Cenários…",
"Goal Seek…" => "Buscar Objetivo…",
"Forecast\nSheet" => "Planilha de\nPrevisão",
"Spelling" => "Ortografia",
"Check\nAccessibility" => "Verificar\nAcessibilidade",
"Threaded Comments" => "Comentários Encadeados",
@ -321,6 +324,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"File Name" => "Nome do Arquivo",
"Sheet Name" => "Nome da Planilha",
"Go to Header" => "Ir para o Cabeçalho",
"Different First Page" => "Primeira Página Diferente",
"Different Odd & Even Pages" => "Páginas Pares e Ímpares Diferentes",
"Go to Footer" => "Ir para o Rodapé",
"Ruler" => "Régua",
// File › Backstage view.
@ -357,9 +362,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Keywords" => "Palavras-chave",
"Description" => "Descrição",
"No workbook is open." => "Nenhuma pasta de trabalho está aberta.",
"Save as type" => "Tipo",
"Excel Workbook (*.xlsx)" => "Pasta de Trabalho do Excel (*.xlsx)",
"CSV UTF-8 (Comma delimited) (*.csv)" => "CSV UTF-8 (Delimitado por vírgulas) (*.csv)",
"Web Page (*.html)" => "Página da Web (*.html)",
"PDF (*.pdf)" => "Arquivo PDF (*.pdf)",
"Download" => "Baixar",
"A CSV file, web page or PDF is a copy: the workbook keeps its own file." => {
"Um arquivo CSV, página da Web ou PDF é uma cópia: a pasta de trabalho mantém o próprio arquivo."
}
"Save the workbook under another name or in another folder." => "Salve a pasta de trabalho com outro nome ou em outra pasta.",
"Download (.xlsx)" => "Baixar (.xlsx)",
"PDF, CSV and web pages are on the Export page." => "PDF, CSV e páginas da Web estão na página Exportar.",
"Print Active Sheets" => "Imprimir planilhas ativas",
"Print Entire Workbook" => "Imprimir toda a pasta de trabalho",
"Fit Sheet on One Page" => "Ajustar planilha em uma página",

View File

@ -171,6 +171,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Clear Validation" => "Очистить проверку данных",
"Group" => "Группировать",
"Ungroup" => "Разгруппировать",
"Show Detail" => "Показать детали",
"Hide Detail" => "Скрыть детали",
"Subtotal" => "Промежуточный итог",
"Data\nAnalysis" => "Анализ данных",
"Data Analysis" => "Анализ данных",
@ -179,6 +181,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Data Table…" => "Таблица данных…",
"Scenario Manager…" => "Диспетчер сценариев…",
"Goal Seek…" => "Подбор параметра…",
"Forecast\nSheet" => "Лист\nпрогноза",
"Spelling" => "Орфография",
"Check\nAccessibility" => "Проверка\nдоступности",
"Threaded Comments" => "Комментарии с ветками",
@ -320,6 +323,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"File Name" => "Имя файла",
"Sheet Name" => "Имя листа",
"Go to Header" => "Перейти к верхнему колонтитулу",
"Different First Page" => "Особый колонтитул для первой страницы",
"Different Odd & Even Pages" => "Разные колонтитулы для четных и нечетных страниц",
"Go to Footer" => "Перейти к нижнему колонтитулу",
"Ruler" => "Линейка",
// File › Backstage view.
@ -356,9 +361,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Keywords" => "Ключевые слова",
"Description" => "Описание",
"No workbook is open." => "Нет открытых книг.",
"Save as type" => "Тип файла",
"Excel Workbook (*.xlsx)" => "Книга Excel (*.xlsx)",
"CSV UTF-8 (Comma delimited) (*.csv)" => "CSV UTF-8 (разделитель — запятая) (*.csv)",
"Web Page (*.html)" => "Веб-страница (*.html)",
"PDF (*.pdf)" => "Файл PDF (*.pdf)",
"Download" => "Скачать",
"A CSV file, web page or PDF is a copy: the workbook keeps its own file." => {
"CSV-файл, веб-страница или PDF — это копия: книга остаётся в своём файле."
}
"Save the workbook under another name or in another folder." => "Сохранить книгу под другим именем или в другой папке.",
"Download (.xlsx)" => "Скачать (.xlsx)",
"PDF, CSV and web pages are on the Export page." => "PDF, CSV и веб-страницы — на странице «Экспорт».",
"Print Active Sheets" => "Напечатать активные листы",
"Print Entire Workbook" => "Напечатать всю книгу",
"Fit Sheet on One Page" => "Вписать лист на одну страницу",

View File

@ -171,6 +171,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Clear Validation" => "清除验证",
"Group" => "组合",
"Ungroup" => "取消组合",
"Show Detail" => "显示明细数据",
"Hide Detail" => "隐藏明细数据",
"Subtotal" => "分类汇总",
"Data\nAnalysis" => "数据分析",
"Data Analysis" => "数据分析",
@ -179,6 +181,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Data Table…" => "数据表…",
"Scenario Manager…" => "方案管理器…",
"Goal Seek…" => "单变量求解…",
"Forecast\nSheet" => "预测工作表",
"Spelling" => "拼写检查",
"Check\nAccessibility" => "检查\n辅助功能",
"Threaded Comments" => "线程式批注",
@ -318,6 +321,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"File Name" => "文件名",
"Sheet Name" => "工作表名",
"Go to Header" => "转至页眉",
"Different First Page" => "首页不同",
"Different Odd & Even Pages" => "奇偶页不同",
"Go to Footer" => "转至页脚",
"Ruler" => "标尺",
// File › Backstage view.
@ -350,9 +355,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Keywords" => "关键词",
"Description" => "说明",
"No workbook is open." => "没有打开的工作簿。",
"Save as type" => "保存类型",
"Excel Workbook (*.xlsx)" => "Excel 工作簿 (*.xlsx)",
"CSV UTF-8 (Comma delimited) (*.csv)" => "CSV UTF-8(逗号分隔) (*.csv)",
"Web Page (*.html)" => "网页 (*.html)",
"PDF (*.pdf)" => "PDF 文件 (*.pdf)",
"Download" => "下载",
"A CSV file, web page or PDF is a copy: the workbook keeps its own file." => "CSV 文件、网页或 PDF 只是副本:工作簿仍保留其原文件。",
"Save the workbook under another name or in another folder." => "以其他名称或在其他文件夹中保存工作簿。",
"Download (.xlsx)" => "下载 (.xlsx)",
"PDF, CSV and web pages are on the Export page." => "PDF、CSV 和网页位于“导出”页。",
"Print Active Sheets" => "打印活动工作表",
"Print Entire Workbook" => "打印整个工作簿",
"Fit Sheet on One Page" => "将工作表调整为一页",

View File

@ -55,10 +55,12 @@ fn borders(preset: &str) -> Shortcut {
/// The shortcut for a key press in the grid, if any. `physical` is the key's position on a US
/// layout (as Excel matches Ctrl+Shift+4 rather than the `$` character); `mac` selects the Excel
/// for Mac variants where they differ.
/// for Mac variants where they differ. Navigation keys go by the logical key: with Num Lock off
/// the numpad's arrows and Page Down report the digit keys (Num6, Num3) as their physical keys.
pub fn lookup(key: Key, physical: Option<Key>, m: Modifiers, mac: bool) -> Option<Shortcut> {
use Key::*;
let k = physical.unwrap_or(key);
let navigation = matches!(key, ArrowLeft | ArrowRight | ArrowUp | ArrowDown | PageUp | PageDown | Home | End | Insert | Delete);
let k = if navigation { key } else { physical.unwrap_or(key) };
// Control itself (Excel for Mac also uses Control for these), Ctrl on Windows / Cmd on Mac,
// and Alt/Option alone.
let ctrl = m.ctrl && !m.alt;
@ -108,6 +110,11 @@ pub fn lookup(key: Key, physical: Option<Key>, m: Modifiers, mac: bool) -> Optio
Period if cmd && s => run("home.increaseFontSize"),
Comma if cmd && s => run("home.decreaseFontSize"),
Space if cmd && s => run("edit.selectAll"),
// Shift+Space selects the row; Ctrl+Space (Windows) / Control+Space (Excel for Mac) the
// column. Cmd+Space stays free: it is the macOS Spotlight shortcut.
Space if plain && s => run("selection.row"),
Space if !mac && cmd && !s => run("selection.column"),
Space if mac && ctrl && !m.command && !s => run("selection.column"),
Home if cmd && s => Shortcut::ExtendToStart,
End if cmd && s => Shortcut::ExtendToEnd,
Home if plain && s => Shortcut::ExtendToRowStart,
@ -184,7 +191,7 @@ pub(crate) fn dispatch(app: &mut SheetApp, ctx: &egui::Context, key: Key, physic
Shortcut::FormulaAbove | Shortcut::ValueAbove => {
let Some(above) = active.row.checked_sub(1).map(|r| CellRef::new(r, active.col)) else { return true };
let text = if shortcut == Shortcut::FormulaAbove {
sh.cell(above).map(|c| c.input_text()).unwrap_or_default()
app.session.active().map(|d| gridcraft_engine::display::edit_text(&d.wb, sh, above)).unwrap_or_default()
} else {
app.session
.active()

View File

@ -14,6 +14,7 @@ pub mod control;
pub mod credits;
pub mod dialogs;
pub mod editor;
pub mod fonts;
pub mod formula_bar;
mod formula_locale;
pub mod grid;
@ -21,6 +22,7 @@ pub mod i18n;
pub mod icons;
pub mod keymap;
pub mod keytips;
pub mod outline;
pub mod panes;
pub mod paper_view;
pub mod pivot_pane;
@ -39,6 +41,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{Value as Json, json};
pub use control::{ControlRequest, ControlResponse};
pub mod desktop_theme;
/// Persisted UI preferences.
#[derive(Clone, Debug, Serialize, Deserialize)]
@ -131,6 +134,23 @@ pub struct SheetView {
pub scroll: egui::Vec2,
}
/// Severity of a notice shown in the info bar under the ribbon.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NoticeKind {
Info,
Warning,
}
/// A dismissible bar under the ribbon, in the spirit of Excel's message bar. Used to tell the
/// user what an opened file carried that GridCraft handled differently (e.g. preserved macros).
#[derive(Clone, Debug, PartialEq)]
pub struct Notice {
pub text: String,
pub kind: NoticeKind,
/// Optional button: `(label, dialog name)` — opens that dialog when clicked.
pub action: Option<(String, String)>,
}
/// Host services the platform shell provides (file pickers, opening URLs).
#[derive(Default)]
pub struct Services {
@ -155,6 +175,9 @@ pub struct Services {
pub open_async: Option<Box<dyn Fn()>>,
/// Files delivered asynchronously (name, bytes), opened on the next frame.
pub inbox: Option<Inbox>,
/// Web: hand PDF bytes to the browser's print flow directly (no download,
/// no intermediate file) — opens the system print dialog on that PDF.
pub print: Option<Box<dyn Fn(&[u8])>>,
}
/// Shared queue of files read asynchronously (browser file picker, drag and drop).
@ -179,6 +202,8 @@ pub struct SheetApp {
pub backstage: Option<backstage::Backstage>,
pub message: Option<(String, String)>,
pub toast: Option<(String, f64)>,
/// Dismissible info bar under the ribbon (open-time notices).
pub notice: Option<Notice>,
pub services: Services,
/// Synthetic input from agents, one batch per frame (drags need several frames).
pub synthetic: std::collections::VecDeque<Vec<egui::Event>>,
@ -187,10 +212,22 @@ pub struct SheetApp {
pub perf: Perf,
pub fonts_ready: bool,
fonts_set: bool,
/// Workbook fonts bound into the current font set; a change rebinds (installed fonts only).
fonts_bound: Vec<String>,
/// The (doc uid, revision) `fonts_bound` was computed for, so the style table is walked only
/// when the workbook changes.
fonts_seen: (u64, u64),
/// Set while a font-set change is pending activation: skip this frame's paint, like the
/// startup `fonts_ready` gate (egui applies new definitions at the next pass).
fonts_rebind: bool,
/// Han face order the installed fonts were built with; a language switch rebuilds the fonts
/// only when it changes this (see `theme::HanOrder`).
fonts_han: Option<theme::HanOrder>,
effective_dark: bool,
/// The desktop's light/dark choice, watched while the app runs (Linux has no winit answer).
desktop_theme: desktop_theme::DesktopTheme,
/// The last answer [`desktop_theme`] gave, so a frame that asks costs nothing.
desktop_dark: Option<bool>,
pub name_box: Option<String>,
/// Transient ribbon keyboard navigation; never saved with UI preferences.
pub keytips: keytips::KeyTips,
@ -212,6 +249,7 @@ impl SheetApp {
backstage: None,
message: None,
toast: None,
notice: None,
services,
synthetic: Default::default(),
control_rx: None,
@ -224,8 +262,13 @@ impl SheetApp {
perf: Perf::default(),
fonts_ready: false,
fonts_set: false,
fonts_bound: Vec::new(),
fonts_seen: (0, 0),
fonts_rebind: false,
fonts_han: None,
effective_dark: false,
desktop_theme: desktop_theme::DesktopTheme::default(),
desktop_dark: None,
name_box: None,
keytips: keytips::KeyTips::default(),
shots: control::Shots::default(),
@ -234,8 +277,12 @@ impl SheetApp {
}
}
/// One-time context setup: fonts and visuals (default Han order; never reads the host locale).
/// One-time context setup: start cataloging the machine's fonts, then install the fonts and
/// visuals. Workbook-specific fonts are bound later, once the scan is done (see [`logic`]).
///
/// [`logic`]: SheetApp::logic
pub fn setup_context(ctx: &egui::Context, dark: bool) {
fonts::start_scan();
Self::setup_context_for_language(ctx, dark, i18n::Language::En);
}
@ -245,8 +292,25 @@ impl SheetApp {
theme::apply(ctx, dark);
}
/// The font names the active workbook uses: every style's font plus the theme's heading/body
/// fonts. These are the families [`fonts`] binds an installed file for.
pub fn used_fonts(&self) -> Vec<String> {
let mut names: Vec<String> = Vec::new();
if let Some(d) = self.session.active() {
names.extend(d.wb.styles.iter().map(|(_, s)| s.font.name.clone()));
names.push(d.wb.theme.major_font.clone());
names.push(d.wb.theme.minor_font.clone());
// Text boxes and shapes name their own typefaces.
names.extend(d.wb.sheets.iter().flat_map(|sh| sh.shapes.iter().filter_map(|s| s.format.font.clone())));
}
names
}
/// Runs an engine command (or a UI command) and handles UI requests it makes.
pub fn run(&mut self, id: &str, params: Json) -> Result<Json, String> {
if matches!(id, "file.save" | "file.saveAs" | "file.saveBytes") {
self.store_view_state();
}
if id.starts_with("file.")
|| matches!(
id,
@ -328,7 +392,17 @@ impl SheetApp {
}
/// Runs a command and shows its error in a message box (for menu/ribbon clicks).
/// Follow the desktop's light/dark choice while the app runs (Linux; the desktop app calls
/// this once — tests and embedders don't start the watcher thread).
pub fn watch_desktop_theme(&mut self) {
self.desktop_theme = desktop_theme::DesktopTheme::start();
}
pub fn run_or_alert(&mut self, id: &str, params: Json) {
// Printing from the UI also hands the PDF to the host's print flow (web).
if id == "file.print" {
return self.print_now(params);
}
if let Err(e) = self.run(id, params) {
self.alert(e);
}
@ -343,6 +417,26 @@ impl SheetApp {
}
}
/// Runs `file.print`, builds the PDF and (where the host provides the hook — the web
/// build) hands the bytes straight to the browser's print flow; errors show the usual
/// alert. Desktop builds have no `services.print`, so this falls back to `run_or_alert`
/// (which still produces the PDF — just without a UI to act on the result yet).
pub fn print_now(&mut self, params: Json) {
match self.run("file.print", params) {
Ok(r) => {
let Some(b64) = r.get("base64").and_then(Json::as_str) else { return };
let Some(bytes) = gridcraft_engine::io::base64_decode(b64) else {
self.message = Some(("GridCraft".into(), "Couldn't decode the generated PDF.".into()));
return;
};
if let Some(print) = &self.services.print {
print(&bytes);
}
}
Err(e) => self.alert(e),
}
}
/// UI-only commands (dialogs, view toggles that aren't saved in the file).
fn run_ui_command(&mut self, id: &str, p: &Json) -> Option<Result<Json, String>> {
let r = match id {
@ -434,6 +528,18 @@ impl SheetApp {
self.message = None;
Ok(Json::Null)
}
"ui.notice" => Ok(match &self.notice {
Some(n) => json!({
"text": n.text,
"kind": match n.kind { NoticeKind::Warning => "warning", NoticeKind::Info => "info" },
"action": n.action,
}),
None => Json::Null,
}),
"ui.notice.dismiss" => {
self.notice = None;
Ok(Json::Null)
}
_ => return None,
};
Some(r)
@ -485,8 +591,9 @@ impl SheetApp {
let path = if std::path::Path::new(&path).extension().is_none() { format!("{path}.xlsx") } else { path };
self.run_or_alert("file.save", json!({"path": path}));
}
} else if let Some(dl) = &self.services.download
&& let Ok(r) = self.session.run("file.saveBytes", json!({"format": "xlsx"}))
} else if self.services.download.is_some()
&& let Ok(r) = self.run("file.saveBytes", json!({"format": "xlsx"}))
&& let Some(dl) = &self.services.download
&& let Some(b) = r.get("base64").and_then(Json::as_str).and_then(gridcraft_engine::io::base64_decode)
{
let name = r.get("name").and_then(Json::as_str).unwrap_or("Book.xlsx").to_string();
@ -517,27 +624,92 @@ impl SheetApp {
pub fn open_path(&mut self, path: &str) {
match self.run("file.open", json!({"path": path})) {
Ok(_) => {
Ok(r) => {
self.ui.recent.retain(|p| p != path);
self.ui.recent.insert(0, path.to_string());
self.ui.recent.truncate(20);
self.show_open_warnings(&r);
}
Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))),
}
self.after_engine();
}
/// Turns the `warnings` a `file.open` result carries into an info-bar notice, choosing the
/// most important one (a preserved VBA project outranks, say, a skipped optional part).
/// Kept deliberately generic: any open-time warning can reach the user this way.
pub fn show_open_warnings(&mut self, r: &Json) {
let Some(list) = r.get("warnings").and_then(Json::as_array) else {
self.notice = None;
return;
};
let msgs: Vec<&str> = list.iter().filter_map(Json::as_str).collect();
if msgs.is_empty() {
// A clean open clears any notice left over from the previous document.
self.notice = None;
return;
}
let vba: Vec<&str> = msgs.iter().copied().filter(|m| m.to_lowercase().contains("macro") || m.contains("VBA")).collect();
if !vba.is_empty() {
self.notice = Some(Notice {
text: "This workbook contains VBA macros. GridCraft keeps them intact when you save, but does not run them.".into(),
kind: NoticeKind::Warning,
action: Some(("What this means".into(), "macros".into())),
});
} else {
let text = if msgs.len() == 1 {
msgs[0].to_string()
} else {
format!("{} items in this file were handled differently. First: {}", msgs.len(), msgs[0])
};
self.notice = Some(Notice { text, kind: NoticeKind::Info, action: None });
}
}
pub fn view_key(&self) -> Option<(u64, usize)> {
self.session.active().map(|d| (d.uid, d.wb.active_sheet))
}
pub fn view(&self) -> SheetView {
self.view_key().and_then(|k| self.views.get(&k).copied()).unwrap_or_default()
self.view_key().and_then(|k| self.views.get(&k).copied()).unwrap_or_else(|| self.saved_view())
}
pub fn view_mut(&mut self) -> Option<&mut SheetView> {
let k = self.view_key()?;
Some(self.views.entry(k).or_default())
let saved = self.saved_view();
Some(self.views.entry(k).or_insert(saved))
}
/// The view a sheet opens with: scrolled to the file's saved first visible cell (`topLeftCell`).
fn saved_view(&self) -> SheetView {
let Some(sh) = self.session.active().and_then(|d| d.wb.active()) else { return SheetView::default() };
if sh.view != gridcraft_engine::model::SheetViewMode::Normal {
return SheetView::default();
}
let (fr, fc) = sh.freeze.unwrap_or((0, 0));
let top = sh.view_top_left;
let y = sh.row_top(top.row.max(fr)) - sh.row_top(fr);
let x = sh.col_left(top.col.max(fc)) - sh.col_left(fc);
SheetView { scroll: egui::vec2(x.max(0.0) as f32, y.max(0.0) as f32) }
}
/// Hands the active document's scroll positions (first visible cell per sheet) and active
/// cell to the engine so a save writes them (`sheetView topLeftCell`). Normal view only:
/// Page Layout view scrolls over pages, not cells.
pub fn store_view_state(&mut self) {
let Some(d) = self.session.active_mut() else { return };
let mut tops = Vec::new();
for (&(uid, si), v) in &self.views {
let Some(sh) = d.wb.sheets.get(si).filter(|sh| uid == d.uid && sh.view == gridcraft_engine::model::SheetViewMode::Normal) else {
continue;
};
let (fr, fc) = sh.freeze.unwrap_or((0, 0));
// A hair past the edge so a row that starts exactly at the scroll offset is the one found.
let row = sh.row_at(sh.row_top(fr) + v.scroll.y.max(0.0) as f64 + 0.01);
let col = sh.col_at(sh.col_left(fc) + v.scroll.x.max(0.0) as f64 + 0.01);
tops.push((si, CellRef::new(row, col)));
}
d.store_view(&tops);
}
/// Starts editing the active cell. `text` replaces the content (typing) or pre-fills it.
@ -559,11 +731,10 @@ impl SheetApp {
let current = if sh.cell_pictures.contains_key(&at) {
String::new()
} else {
sh.cell(at)
.map(
|c| if c.formula.is_some() { formula_locale::display(&c.input_text(), locale, &d.wb, d.wb.active_sheet) } else { c.input_text() },
)
.unwrap_or_default()
match sh.cell(at) {
Some(c) if c.formula.is_some() => formula_locale::display(&c.input_text(), locale, &d.wb, d.wb.active_sheet),
_ => gridcraft_engine::display::edit_text(&d.wb, sh, at),
}
};
let (text, replace) = match text {
Some(t) => (t, true),
@ -661,11 +832,42 @@ impl SheetApp {
} else {
SheetApp::setup_context_for_language(ctx, self.ui.dark, self.ui.language);
self.fonts_set = true;
// The rebuilt set has no workbook fonts yet: bind them again once it is active.
self.fonts_bound.clear();
self.fonts_seen = (0, 0);
self.fonts_han = Some(theme::HanOrder::of(self.ui.language));
ctx.request_repaint();
}
} else if self.fonts_rebind {
// A font-set change made last frame is active now.
self.fonts_rebind = false;
} else if fonts::scan_done() {
// Bind the workbook's installed fonts once the background scan has finished. Adding
// fonts re-applies the font set, which egui activates at the next pass, so skip this
// frame's paint (same one-frame cost as startup). Only when the workbook changed.
let seen = self.session.active().map(|d| (d.uid, d.revision)).unwrap_or((0, 0));
if seen != self.fonts_seen {
let used = self.used_fonts();
self.fonts_seen = seen;
if used != self.fonts_bound {
ctx.set_fonts(theme::font_definitions_with(self.ui.language, &used));
self.fonts_bound = used;
self.fonts_rebind = true;
ctx.request_repaint();
}
}
}
let preference = self.ui.theme_preference();
if let Some(answer) = self.desktop_theme.take_change() {
self.desktop_dark = Some(answer);
}
let preference = match (
self.ui.system_theme,
ctx.system_theme().or_else(|| self.desktop_dark.map(|d| if d { egui::Theme::Dark } else { egui::Theme::Light })),
) {
(true, Some(egui::Theme::Dark)) => egui::ThemePreference::Dark,
(true, Some(egui::Theme::Light)) => egui::ThemePreference::Light,
_ => self.ui.theme_preference(),
};
if ctx.options(|o| o.theme_preference) != preference {
ctx.set_theme(preference);
ctx.request_repaint();
@ -693,7 +895,10 @@ impl SheetApp {
let b64 = gridcraft_engine::io::base64_encode(&bytes);
match self.run("file.open", json!({"name": name, "base64": b64})) {
// A file picked from File › Open (web) arrives here: show it, not the Backstage.
Ok(_) => self.backstage = None,
Ok(r) => {
self.backstage = None;
self.show_open_warnings(&r);
}
Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))),
}
self.after_engine();
@ -712,7 +917,7 @@ impl SheetApp {
/// Lays out the whole window.
pub fn ui(&mut self, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone();
if !self.fonts_ready {
if !self.fonts_ready || self.fonts_rebind {
ctx.request_repaint();
return;
}
@ -730,6 +935,7 @@ impl SheetApp {
backstage::show(self, ui);
} else {
ribbon::show(self, ui);
widgets::notice_bar(self, ui);
if self.session.prefs.formula_bar {
formula_bar::show(self, ui);
}
@ -770,6 +976,7 @@ impl SheetApp {
&& self.session.active().is_some_and(|d| d.is_dirty() && d.path.as_deref().is_some_and(|p| p.ends_with(".xlsx")))
{
self.grid.last_autosave = now;
self.store_view_state();
let _ = self.session.run("file.save", json!({}));
}
// Window title.

View File

@ -0,0 +1,400 @@
//! The outline band of grouped rows and columns (Data ▸ Group), as in Excel: a strip left of the
//! row headers and one above the column headers, one lane per outline level, with a bracket per
//! expanded group, a − / + button on its summary line (below / right of the detail) and the level
//! buttons 1…n+1 in the corner. The bands exist only while the sheet has an outline.
//!
//! Layout and hit-testing are pure functions of [`Geo`] in logical (left-to-right) coordinates,
//! so right-to-left sheets get them mirrored with the rest of the grid. Clicks run commands
//! (`data.hideDetail`, `data.showDetail`, `data.showLevels`), so they undo like any edit.
use egui::{Align2, Painter, Pos2, Rect, Stroke, StrokeKind, pos2, vec2};
use gridcraft_engine::core::col_to_letters;
use gridcraft_engine::model::{LineInfo, Sheet};
use gridcraft_engine::outline::{self, OutlineGroup};
use serde_json::{Value as Json, json};
use std::collections::BTreeMap;
use crate::grid::{DISPLAY_SCALE, Geo};
use crate::theme::{self, Tokens};
/// Width of one level's lane at 100% zoom (points).
const LANE: f32 = 15.0;
/// The outline of one direction (rows or columns): its deepest level and its groups.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Axis {
pub levels: u8,
pub groups: Vec<OutlineGroup>,
}
impl Axis {
pub fn new(lines: &BTreeMap<u32, LineInfo>) -> Axis {
Axis { levels: outline::max_level(lines), groups: outline::groups(lines) }
}
}
/// One lane's width at this zoom (`zoom` is the sheet zoom, 1.0 = 100%).
pub fn lane(zoom: f32) -> f32 {
(LANE * zoom.max(0.75)).round()
}
/// Thickness of the band for an outline `levels` deep: a lane per level plus one for the level
/// button that shows everything. No outline, no band.
pub fn band(levels: u8, zoom: f32) -> f32 {
if levels == 0 { 0.0 } else { (levels as f32 + 1.0) * lane(zoom) + 3.0 }
}
/// What a button does.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
/// A level button: show `rows` (or columns) down to this level.
Level { rows: bool, level: u8 },
/// A group's − (hide its detail) or + (show it) button.
Toggle { rows: bool, group: OutlineGroup },
}
impl Action {
/// The command (and its parameters) the button runs.
pub fn command(&self) -> (&'static str, Json) {
match *self {
Action::Level { rows, level } => ("data.showLevels", json!({ if rows { "rows" } else { "cols" }: level })),
Action::Toggle { rows, group } => {
let id = if group.collapsed { "data.showDetail" } else { "data.hideDetail" };
let (key, lines) = if rows {
("rows", format!("{}:{}", group.start as u64 + 1, group.end as u64 + 1))
} else {
("cols", format!("{}:{}", col_to_letters(group.start), col_to_letters(group.end)))
};
(id, json!({ key: lines, "level": group.level }))
}
}
}
/// The button's label: the level digit, − or +.
pub fn label(&self) -> String {
match *self {
Action::Level { level, .. } => level.to_string(),
Action::Toggle { group, .. } => if group.collapsed { "+" } else { "\u{2212}" }.to_string(),
}
}
}
/// A button in the band.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Button {
pub rect: Rect,
pub action: Action,
}
/// An expanded group's bracket: a line along its lane from the first detail line to its button,
/// with a tick at the start (`tick` is `None` when the start is scrolled out of view).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Bracket {
pub line: [Pos2; 2],
pub tick: Option<[Pos2; 2]>,
}
/// Everything drawn in the bands this frame.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Layout {
pub buttons: Vec<Button>,
pub brackets: Vec<Bracket>,
}
/// The row band (left of the row headers) and the column band (above the column headers).
pub fn bands(geo: &Geo) -> (Rect, Rect) {
let r = geo.rect;
let rows = Rect::from_min_max(r.min, pos2(r.left() + geo.band_w, r.bottom()));
let cols = Rect::from_min_max(r.min, pos2(r.right(), r.top() + geo.band_h));
(rows, cols)
}
/// One direction's geometry, in (along, across) coordinates: along the lines, across the lanes.
struct Dir<'a> {
rows: bool,
geo: &'a Geo,
sh: &'a Sheet,
/// The cells' extent along, where the scrolling pane begins, and the first scrolling line.
lo: f32,
hi: f32,
split: f32,
frozen: u32,
/// Lane `k`'s centre across (1-based).
lane0: f32,
/// Centre across, and along, of this direction's level buttons.
level_along: f32,
}
impl Dir<'_> {
/// Where the line starts on screen, and its size.
fn start(&self, line: u32) -> f32 {
if self.rows { self.geo.y(self.sh, line) } else { self.geo.x(self.sh, line) }
}
fn size(&self, line: u32) -> f32 {
self.geo.z * if self.rows { self.sh.row_height(line) } else { self.sh.col_width(line) }
}
fn lane(&self, step: f32, k: u8) -> f32 {
self.lane0 + (k as f32 - 0.5) * step
}
/// The visible extent of a line along: clipped to its pane, `None` when hidden or off screen.
fn visible(&self, line: u32) -> Option<(f32, f32)> {
let (a, size) = (self.start(line), self.size(line));
let top = if line >= self.frozen { self.split } else { self.lo };
let (a0, a1) = (a.max(top), (a + size).min(self.hi));
(size > 0.0 && a1 > a0).then_some((a0, a1))
}
fn point(&self, along: f32, across: f32) -> Pos2 {
if self.rows { pos2(across, along) } else { pos2(along, across) }
}
fn rect(&self, along: f32, across: f32, side: f32) -> Rect {
Rect::from_center_size(self.point(along, across), vec2(side, side))
}
}
/// The band's buttons and brackets for this frame.
pub fn layout(geo: &Geo, sh: &Sheet) -> Layout {
let mut out = Layout::default();
let zoom = (geo.z / DISPLAY_SCALE).max(0.1);
let step = lane(zoom);
let side = (step - 4.0).max(7.0);
let corner_h = if geo.header_h > 0.0 { geo.header_h } else { geo.band_h };
let corner_w = if geo.header_w > 0.0 { geo.header_w } else { geo.band_w };
let rows = Dir {
rows: true,
geo,
sh,
lo: geo.cells.top(),
hi: geo.cells.bottom(),
split: geo.cells.top() + geo.frozen_h,
frozen: geo.fr,
lane0: geo.rect.left() + 2.0,
level_along: geo.cells.top() - corner_h / 2.0,
};
let cols = Dir {
rows: false,
geo,
sh,
lo: geo.cells.left(),
hi: geo.cells.right(),
split: geo.cells.left() + geo.frozen_w,
frozen: geo.fc,
lane0: geo.rect.top() + 2.0,
level_along: geo.cells.left() - corner_w / 2.0,
};
for (dir, axis) in [(&rows, &geo.row_outline), (&cols, &geo.col_outline)] {
if axis.levels == 0 {
continue;
}
for k in 1..=axis.levels + 1 {
out.buttons.push(Button { rect: dir.rect(dir.level_along, dir.lane(step, k), side), action: Action::Level { rows: dir.rows, level: k } });
}
for grp in &axis.groups {
let across = dir.lane(step, grp.level);
// The button on the summary line, if that line is on screen.
let button = dir.visible(grp.summary()).map(|(a0, a1)| (a0 + a1) / 2.0).filter(|_| grp.summary() > grp.end);
if let Some(mid) = button {
out.buttons.push(Button { rect: dir.rect(mid, across, side), action: Action::Toggle { rows: dir.rows, group: *grp } });
}
if grp.collapsed {
continue;
}
// The bracket, over the part of the detail that is on screen.
let first = (grp.start..=grp.end).take(4096).find_map(|l| dir.visible(l).map(|v| (l, v)));
let Some((first_line, (a0, _))) = first else { continue };
let a1 = match button {
Some(mid) => mid - side / 2.0,
None => dir.start(grp.summary()).min(dir.hi),
};
if a1 <= a0 {
continue;
}
let start_shown = first_line == grp.start && (a0 - dir.start(grp.start)).abs() < 0.5;
let tick = start_shown.then(|| [dir.point(a0 + 0.5, across), dir.point(a0 + 0.5, across + side / 2.0)]);
out.brackets.push(Bracket { line: [dir.point(a0 + 0.5, across), dir.point(a1, across)], tick });
}
}
out
}
/// The button under `p` (logical coordinates), if any.
pub fn hit(geo: &Geo, sh: &Sheet, p: Pos2) -> Option<Action> {
let (rows, cols) = bands(geo);
if !rows.contains(p) && !cols.contains(p) {
return None;
}
layout(geo, sh).buttons.into_iter().find(|b| b.rect.expand(1.5).contains(p)).map(|b| b.action)
}
/// Paints the bands (in logical coordinates, before the grid is mirrored for right-to-left).
pub fn paint(p: &Painter, geo: &Geo, sh: &Sheet, t: &Tokens) {
if geo.band_w <= 0.0 && geo.band_h <= 0.0 {
return;
}
let (rows, cols) = bands(geo);
for band in [rows, cols] {
if band.width() > 0.0 && band.height() > 0.0 {
p.rect_filled(band, 0.0, t.window);
}
}
if geo.band_w > 0.0 {
p.line_segment([pos2(rows.right() - 0.5, rows.top()), pos2(rows.right() - 0.5, rows.bottom())], Stroke::new(1.0, t.header_line));
}
if geo.band_h > 0.0 {
p.line_segment([pos2(cols.left(), cols.bottom() - 0.5), pos2(cols.right(), cols.bottom() - 0.5)], Stroke::new(1.0, t.header_line));
}
let l = layout(geo, sh);
let ink = Stroke::new(1.0, t.header_text);
// Brackets stay out of the corner, where the level buttons are.
let row_clip = Rect::from_min_max(pos2(rows.left(), geo.cells.top()), rows.max);
let col_clip = Rect::from_min_max(pos2(geo.cells.left(), cols.top()), cols.max);
for b in &l.brackets {
let clip = if b.line[0].x == b.line[1].x { row_clip } else { col_clip };
let pc = p.with_clip_rect(clip);
pc.line_segment(b.line, ink);
if let Some(tick) = b.tick {
pc.line_segment(tick, ink);
}
}
let font = theme::ui_font((10.0 * (geo.z / DISPLAY_SCALE).max(0.75)).min(14.0));
for b in &l.buttons {
let clip = match b.action {
Action::Toggle { rows: true, .. } => row_clip,
Action::Toggle { rows: false, .. } => col_clip,
Action::Level { .. } => geo.rect,
};
let pc = p.with_clip_rect(clip);
pc.rect_filled(b.rect, 1.0, t.header_bg);
pc.rect_stroke(b.rect, 1.0, ink, StrokeKind::Inside);
pc.text(b.rect.center(), Align2::CENTER_CENTER, b.action.label(), font.clone(), t.header_text);
}
}
#[cfg(test)]
mod tests {
use super::*;
use gridcraft_engine::core::CellRef;
/// Rows 5:8 grouped with 6:7 inside, columns C:E grouped.
fn sheet() -> Sheet {
let mut sh = Sheet::new("Sheet1");
for (r, level) in [(4, 1), (5, 2), (6, 2), (7, 1)] {
sh.rows.entry(r).or_default().outline = level;
}
for c in 2..=4 {
sh.cols.entry(c).or_default().outline = 1;
}
sh
}
fn geo(sh: &Sheet) -> Geo {
Geo::new(sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 700.0)), vec2(0.0, 0.0))
}
#[test]
fn no_outline_no_band() {
let sh = Sheet::new("Sheet1");
let g = geo(&sh);
assert_eq!((g.band_w, g.band_h), (0.0, 0.0));
assert_eq!(layout(&g, &sh), Layout::default());
assert_eq!(g.head_left(), g.rect.left());
}
#[test]
fn bands_push_the_headers_and_cells_in() {
let sh = sheet();
let g = geo(&sh);
let plain = geo(&Sheet::new("Sheet1"));
assert_eq!(g.band_w, band(2, 1.0));
assert_eq!(g.band_h, band(1, 1.0));
assert!(g.band_w > g.band_h && g.band_h > 0.0);
assert_eq!(g.head_left(), g.rect.left() + g.band_w);
assert_eq!(g.head_top(), g.rect.top() + g.band_h);
assert_eq!(g.cells.left(), plain.cells.left() + g.band_w);
assert_eq!(g.cells.top(), plain.cells.top() + g.band_h);
}
#[test]
fn level_buttons_sit_in_the_corner_and_toggles_on_summary_lines() {
let sh = sheet();
let g = geo(&sh);
let l = layout(&g, &sh);
let levels: Vec<_> =
l.buttons.iter().filter_map(|b| if let Action::Level { rows, level } = b.action { Some((rows, level)) } else { None }).collect();
assert_eq!(levels, [(true, 1), (true, 2), (true, 3), (false, 1), (false, 2)]);
// Row level buttons: side by side in the row band, level with the column headers.
let row1 = l.buttons.iter().find(|b| b.action == Action::Level { rows: true, level: 1 }).unwrap().rect;
let row2 = l.buttons.iter().find(|b| b.action == Action::Level { rows: true, level: 2 }).unwrap().rect;
assert!(row1.right() < row2.left() && (row1.center().y - row2.center().y).abs() < 0.01);
assert!(row1.center().y > g.head_top() && row1.center().y < g.cells.top() && row2.right() <= g.head_left());
// The level-1 row group's − is on row 9 (index 8) in lane 1; the inner group's on row 8.
let toggle = |rows: bool, level: u8| {
l.buttons.iter().find(|b| matches!(b.action, Action::Toggle { rows: r, group } if r == rows && group.level == level)).unwrap()
};
let outer = toggle(true, 1);
assert!(g.cell_rect(&sh, CellRef::new(8, 0)).y_range().contains(outer.rect.center().y));
assert!((outer.rect.center().x - row1.center().x).abs() < 0.01);
let inner = toggle(true, 2);
assert!(g.cell_rect(&sh, CellRef::new(7, 0)).y_range().contains(inner.rect.center().y));
assert!((inner.rect.center().x - row2.center().x).abs() < 0.01);
// Column group C:E: its − is above column F.
let col = toggle(false, 1);
assert!(g.cell_rect(&sh, CellRef::new(0, 5)).x_range().contains(col.rect.center().x));
assert!(col.rect.bottom() <= g.head_top());
// Brackets: one per expanded group, from the first detail line to its button.
assert_eq!(l.brackets.len(), 3);
let b = l.brackets.iter().find(|b| (b.line[0].x - outer.rect.center().x).abs() < 0.01).unwrap();
assert!((b.line[0].y - g.y(&sh, 4)).abs() < 1.0 && (b.line[1].y - outer.rect.top()).abs() < 0.01);
assert!(b.tick.is_some());
assert_eq!(outer.action.label(), "\u{2212}");
}
#[test]
fn collapsed_groups_show_plus_and_no_bracket() {
let mut sh = sheet();
for r in 4..=7 {
sh.rows.entry(r).or_default().hidden = true;
}
let g = geo(&sh);
let l = layout(&g, &sh);
let outer = l.buttons.iter().find(|b| matches!(b.action, Action::Toggle { rows: true, group } if group.level == 1)).unwrap();
assert_eq!(outer.action.label(), "+");
// The inner group's summary row is hidden with the rest: no button for it.
assert!(!l.buttons.iter().any(|b| matches!(b.action, Action::Toggle { rows: true, group } if group.level == 2)));
assert_eq!(l.brackets.len(), 1); // only the columns' bracket
assert_eq!(outer.action.command(), ("data.showDetail", json!({"rows": "5:8", "level": 1})));
}
#[test]
fn hit_finds_buttons_and_maps_them_to_commands() {
let sh = sheet();
let g = geo(&sh);
let l = layout(&g, &sh);
for b in &l.buttons {
assert_eq!(hit(&g, &sh, b.rect.center()), Some(b.action));
}
assert_eq!(hit(&g, &sh, g.cells.center()), None);
assert_eq!(hit(&g, &sh, pos2(g.rect.left() + 1.0, g.cells.bottom() - 2.0)), None);
let col = l.buttons.iter().find(|b| matches!(b.action, Action::Toggle { rows: false, .. })).unwrap();
assert_eq!(col.action.command(), ("data.hideDetail", json!({"cols": "C:E", "level": 1})));
assert_eq!(Action::Level { rows: true, level: 2 }.command(), ("data.showLevels", json!({"rows": 2})));
assert_eq!(Action::Level { rows: false, level: 1 }.command(), ("data.showLevels", json!({"cols": 1})));
}
#[test]
fn groups_scrolled_under_frozen_rows_stay_out_of_their_band() {
let mut sh = sheet();
sh.freeze = Some((10, 0));
// Rows 20:22 (summary row 23) scrolled under the ten frozen rows; rows 5:8 are frozen.
for r in 19..=21 {
sh.rows.entry(r).or_default().outline = 1;
}
let g = Geo::new(&sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 700.0)), vec2(0.0, 14.0 * sh.default_row_height));
let l = layout(&g, &sh);
let split = g.cells.top() + g.frozen_h;
let low = |grp: &OutlineGroup| grp.start == 19;
assert!(!l.buttons.iter().any(|b| matches!(b.action, Action::Toggle { group, .. } if low(&group))));
for b in l.buttons.iter().filter(|b| matches!(b.action, Action::Toggle { rows: true, group } if group.start >= 10)) {
assert!(b.rect.center().y >= split);
}
assert!(l.buttons.iter().any(|b| matches!(b.action, Action::Toggle { rows: true, group } if group.start == 4)));
}
}

View File

@ -12,7 +12,7 @@ use std::sync::Arc;
use egui::{Align2, Color32, FontId, Key, Painter, Pos2, Rect, Stroke, StrokeKind, TextEdit, pos2, vec2};
use gridcraft_engine::cf::CfCache;
use gridcraft_engine::cmd::print::{self, HfContext};
use gridcraft_engine::model::{Sheet, SheetViewMode, Workbook};
use gridcraft_engine::model::{HfPage, Sheet, SheetViewMode, Workbook};
use gridcraft_engine::paper::{self, HfZone, PageGrid};
use serde_json::json;
@ -40,6 +40,8 @@ pub struct HfEdit {
/// Page (row band, column band) whose header/footer is shown in edit.
pub page: (usize, usize),
pub footer: bool,
/// Which header/footer pair the page uses (first page, even pages, the rest).
pub kind: HfPage,
/// 0 left, 1 centre, 2 right.
pub section: usize,
/// The three sections as the user sees them (`&[Page]` field names).
@ -210,14 +212,15 @@ pub(crate) fn paint_headers_footers(app: &SheetApp, ui: &egui::Ui, p: &Painter,
(geo.scroll.x as f64 + ((q.x - geo.cells.left()) / geo.z) as f64, geo.scroll.y as f64 + ((q.y - geo.cells.top()) / geo.z) as f64);
pg.hf_zone_at(x, y)
});
let needs_text = !sh.print.header.is_empty() || !sh.print.footer.is_empty();
let needs_text = sh.print.has_header_footer();
let (date, time) = if needs_text { print::hf_date_time(wb) } else { Default::default() };
for (i, j) in pages_iter(geo, &pg) {
let page = screen_rect(geo, pg.page_rect(i, j));
if !page.intersects(geo.cells) {
continue;
}
for (spec, footer) in [(&sh.print.header, false), (&sh.print.footer, true)] {
let kind = page_kind(sh, &pg, (i, j));
for (spec, footer) in [(sh.print.hf(kind, false), false), (sh.print.hf(kind, true), true)] {
if editing == Some(((i, j), footer)) {
continue;
}
@ -281,6 +284,9 @@ pub(crate) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo
commit(app);
}
let Some(pg) = geo.paper.clone() else { return };
if std::mem::take(&mut app.grid.hf_start) {
begin(app, wb.active_sheet, sh, (pg.data_rows.0, pg.data_cols.0), HfZone { footer: false, section: 1 });
}
if resp.clicked_by(egui::PointerButton::Primary)
&& let Some(p) = ui.input(|i| i.pointer.interact_pos())
{
@ -299,6 +305,43 @@ pub(crate) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo
editor(app, ui, geo, &pg);
}
/// The `pageLayout.headerFooter` parameter holding one kind of page's header or footer.
fn hf_key(kind: HfPage, footer: bool) -> &'static str {
match (kind, footer) {
(HfPage::Odd, false) => "header",
(HfPage::Odd, true) => "footer",
(HfPage::Even, false) => "evenHeader",
(HfPage::Even, true) => "evenFooter",
(HfPage::First, false) => "firstHeader",
(HfPage::First, true) => "firstFooter",
}
}
/// Insert › Header & Footer: switches to Page Layout view and edits the first page's centre header.
pub fn start_editing(app: &mut SheetApp) {
if app.session.active().and_then(|d| d.wb.active()).is_some_and(|sh| sh.view != SheetViewMode::PageLayout) {
app.run_or_alert("view.pageLayout", json!({}));
}
app.grid.hf_start = true;
}
/// Turns Different First Page / Different Odd & Even Pages on or off, keeping the edit on the
/// same page (which may now show another header).
fn set_option(app: &mut SheetApp, key: &str, on: bool) {
let Some(e) = app.grid.hf_edit.as_ref() else { return };
let (sheet, page, zone) = (e.sheet, e.page, HfZone { footer: e.footer, section: e.section });
commit(app);
app.run_or_alert("pageLayout.headerFooter", json!({"sheet": sheet, key: on}));
let Some(sh) = app.session.active().and_then(|d| d.wb.sheet(sheet)).cloned() else { return };
begin(app, sheet, &sh, page, zone);
}
/// Which header/footer pair a page shows: the first page, an even page, or the rest.
fn page_kind(sh: &Sheet, pg: &PageGrid, page: (usize, usize)) -> HfPage {
let n = pg.page_number(page.0, page.1).map_or(1, |(n, _)| n);
sh.print.hf_page(n == 1, n)
}
/// Starts (or moves) editing a header or footer section.
pub fn begin(app: &mut SheetApp, sheet: usize, sh: &Sheet, page: (usize, usize), zone: HfZone) {
if let Some(e) = app.grid.hf_edit.as_mut()
@ -321,14 +364,19 @@ pub fn begin(app: &mut SheetApp, sheet: usize, sh: &Sheet, page: (usize, usize),
if app.editor.is_some() && !app.commit_edit(0, 0, false, false) {
return;
}
let spec = if zone.footer { &sh.print.footer } else { &sh.print.header };
let texts = print::split_header_footer(spec).map(|s| print::codes_to_fields(&s));
// Before the view has paginated this sheet, the top-left page is taken as the first.
let kind = match app.grid.paper.as_ref().filter(|(key, _)| key.2 == sheet) {
Some((_, pg)) => page_kind(sh, pg, page),
None => sh.print.hf_page(page == (0, 0), 1),
};
let texts = print::split_header_footer(sh.print.hf(kind, zone.footer)).map(|s| print::codes_to_fields(&s));
let caret = texts.get(zone.section).map_or(0, |t| t.chars().count());
let prev_tab = if app.ui.ribbon_tab == TAB { "Home".to_string() } else { app.ui.ribbon_tab.clone() };
app.grid.hf_edit = Some(HfEdit {
sheet,
page,
footer: zone.footer,
kind,
section: zone.section.min(2),
original: texts.clone(),
texts,
@ -351,7 +399,7 @@ pub fn commit(app: &mut SheetApp) {
return;
}
let spec = print::join_header_footer(&e.texts.clone().map(|s| print::fields_to_codes(&s)));
let key = if e.footer { "footer" } else { "header" };
let key = hf_key(e.kind, e.footer);
app.run_or_alert("pageLayout.headerFooter", json!({"sheet": e.sheet, key: spec}));
}
@ -550,7 +598,7 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo,
pub fn ribbon_tab(app: &mut SheetApp, ui: &mut egui::Ui) {
use crate::widgets::big_button;
let Some(e) = app.grid.hf_edit.as_ref() else { return };
let footer = e.footer;
let (footer, kind) = (e.footer, e.kind);
let (sheet_name, file) =
app.session.active().map(|d| (d.wb.sheet(e.sheet).map(|s| s.name.clone()).unwrap_or_default(), d.display_title())).unwrap_or_default();
// Header & Footer presets (shown with sample values, as the result will read).
@ -570,7 +618,7 @@ pub fn ribbon_tab(app: &mut SheetApp, ui: &mut egui::Ui) {
if ui.button(text).clicked() {
let sheet = app.grid.hf_edit.as_ref().map(|e| e.sheet).unwrap_or(0);
cancel(app);
app.run_or_alert("pageLayout.headerFooter", json!({"sheet": sheet, (if part { "footer" } else { "header" }): spec}));
app.run_or_alert("pageLayout.headerFooter", json!({"sheet": sheet, hf_key(kind, part): spec}));
ui.close();
}
}
@ -597,6 +645,21 @@ pub fn ribbon_tab(app: &mut SheetApp, ui: &mut egui::Ui) {
if big_button(ui, Icon::Footer, "Go to\nFooter", "Go to Footer", false).clicked() && !footer {
go_to(app, true);
}
sep(ui);
let (first, odd_even) = app
.session
.active()
.and_then(|d| d.wb.sheet(app.grid.hf_edit.as_ref().map_or(0, |e| e.sheet)).map(|s| (s.print.different_first, s.print.different_odd_even)))
.unwrap_or_default();
ui.vertical(|ui| {
let (mut f, mut o) = (first, odd_even);
if ui.checkbox(&mut f, tr(app, "Different First Page")).changed() {
set_option(app, "differentFirst", f);
}
if ui.checkbox(&mut o, tr(app, "Different Odd & Even Pages")).changed() {
set_option(app, "differentOddEven", o);
}
});
}
fn sep(ui: &mut egui::Ui) {

View File

@ -98,7 +98,6 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) {
("New Workbook", "file.new"),
("Open…", "file.open"),
("Save As…", "file.saveAs"),
("Print…", "file.print"),
("Sort A to Z", "data.sortAscending"),
("Calculate Now", "formulas.calculateNow"),
] {
@ -106,6 +105,9 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) {
app.run_or_alert(id, json!({}));
}
}
if ui.button("Print…").clicked() {
app.print_now(json!({}));
}
});
// Centered title.
let title = app.session.active().map(|d| d.display_title()).unwrap_or_default();
@ -827,9 +829,9 @@ fn font_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) {
let mut name = st.font.name.clone();
let before = name.clone();
egui::ComboBox::from_id_salt("font_name").width(150.0).selected_text(egui::RichText::new(&name).font(theme::ui_font(12.5))).show_ui(ui, |ui| {
for f in FONTS {
let fam = theme::cell_family(f, false, false);
ui.selectable_value(&mut name, f.to_string(), egui::RichText::new(*f).font(egui::FontId::new(14.0, fam)));
for f in crate::fonts::families() {
let fam = theme::cell_family(&f, false, false);
ui.selectable_value(&mut name, f.clone(), egui::RichText::new(&f).font(egui::FontId::new(14.0, fam)));
}
});
if name != before {
@ -1264,6 +1266,10 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) {
});
});
sep(ui);
if big_button(ui, Icon::Filter, "Slicer", "Insert a slicer to filter a table", false).clicked() {
act(app, "insert.slicer", json!({}));
}
sep(ui);
let sp = big_button(ui, Icon::Sparkline, "Sparklines", "Sparklines", true);
egui::Popup::menu(&sp).show(|ui| {
for (label, kind) in [("Line", "line"), ("Column", "column"), ("Win/Loss", "winLoss")] {
@ -1289,7 +1295,8 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) {
act(app, "insert.textBox", json!({}));
}
if big_button(ui, Icon::PageLayout, "Header &\nFooter", "Header & Footer", false).clicked() {
app.open_dialog("headerFooter", json!({}));
// As in Excel: Page Layout view, editing the header in place (the dialog is under Page Setup).
crate::paper_view::start_editing(app);
}
let sym = big_button(ui, Icon::Symbol, "Symbol", "Symbol", true);
egui::Popup::menu(&sym).show(|ui| {
@ -1537,10 +1544,10 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) {
if small_button(ui, Icon::Calc, "Evaluate Formula", "Evaluate Formula", false).clicked() {
app.open_dialog("evaluateFormula", json!({}));
}
if small_button(ui, Icon::Search, "Watch Window", "Watch Window", false).clicked() {
app.grid.pane = Some("watch".into());
}
});
if big_button(ui, Icon::Search, "Watch\nWindow", "Watch Window", false).clicked() {
app.grid.pane = Some("watch".into());
}
sep(ui);
let co = big_button(ui, Icon::Calc, "Calculation\nOptions", "Calculation Options", true);
egui::Popup::menu(&co).show(|ui| {
@ -1627,6 +1634,9 @@ fn data(app: &mut SheetApp, ui: &mut Ui) {
],
);
});
if big_button(ui, Icon::ChartLine, "Forecast\nSheet", "Forecast Sheet", false).clicked() {
app.open_dialog("forecastSheet", json!({}));
}
sep(ui);
ui.vertical(|ui| {
if small_button(ui, Icon::Group, "Group", "Group", false).clicked() {
@ -1639,6 +1649,14 @@ fn data(app: &mut SheetApp, ui: &mut Ui) {
app.open_dialog("subtotal", json!({}));
}
});
ui.vertical(|ui| {
if small_button(ui, Icon::Plus, "Show Detail", "Show Detail", false).clicked() {
act(app, "data.showDetail", json!({}));
}
if small_button(ui, Icon::Minus, "Hide Detail", "Hide Detail", false).clicked() {
act(app, "data.hideDetail", json!({}));
}
});
sep(ui);
if big_button(ui, Icon::ChartBar, "Data\nAnalysis", "Data Analysis", false).clicked() {
app.open_dialog("analysisToolPak", json!({}));
@ -1711,7 +1729,7 @@ fn review(app: &mut SheetApp, ui: &mut Ui) {
}
}
if big_button(ui, Icon::Book, "Protect\nWorkbook", "Protect Workbook structure", false).clicked() {
act(app, "review.protectWorkbook", json!({}));
crate::backstage::protect_workbook(app);
}
if big_button(ui, Icon::Validation, "Allow Edit\nRanges", "Allow Edit Ranges", false).clicked() {
app.open_dialog("allowEditRanges", json!({}));
@ -1835,6 +1853,9 @@ fn view(app: &mut SheetApp, ui: &mut Ui) {
}
fn automate(app: &mut SheetApp, ui: &mut Ui) {
if big_button(ui, Icon::Script, "Macros", "Macros and scripts in this workbook (Alt+F8)", false).clicked() {
app.open_dialog("macros", json!({}));
}
if big_button(ui, Icon::Script, "Command\nPalette", "Search and run any command", false).clicked() {
app.open_dialog("commandSearch", json!({}));
}
@ -1874,6 +1895,10 @@ fn table_design(app: &mut SheetApp, ui: &mut Ui) {
act(app, "table.convertToRange", json!({"table": tname}));
}
sep(ui);
if big_button(ui, Icon::Filter, "Insert\nSlicer", "Insert Slicer", false).clicked() {
act(app, "table.insertSlicer", json!({"table": tname}));
}
sep(ui);
ui.vertical(|ui| {
for (label, cmd, v) in [
("Header Row", "table.headerRow", header && filter),
@ -1980,6 +2005,10 @@ pub fn shortcut(app: &mut SheetApp, key: Key, m: Modifiers) {
Key::O => Some(("file.open", json!({}))),
Key::N => Some(("file.new", json!({}))),
Key::W => Some(("file.close", json!({}))),
Key::P => {
app.print_now(json!({}));
None
}
Key::K => {
app.open_dialog("insertLink", json!({}));
None

View File

@ -164,8 +164,9 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
app.run_or_alert("sheet.unhide", json!({}));
}
ui.separator();
if ui.button("Protect Sheet…").clicked() {
app.open_dialog("protectSheet", json!({}));
let protected = app.session.active().and_then(|d| d.wb.active().map(|s| s.is_protected())).unwrap_or(false);
if ui.button(if protected { "Unprotect Sheet…" } else { "Protect Sheet…" }).clicked() {
app.open_dialog(if protected { "unprotectSheet" } else { "protectSheet" }, json!({}));
}
if ui.button("Select All Sheets").clicked() {
ui.close();
@ -229,6 +230,10 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) {
};
ui.label(egui::RichText::new(app.ui.language.tr(mode)).font(theme::ui_font(12.5)).color(t.text_dim));
if let Some(d) = app.session.active() {
if d.wb.vba_project.is_some() {
ui.add_space(12.0);
ui.label(egui::RichText::new("Macros preserved (not executed)").font(theme::ui_font(12.5)).color(t.danger));
}
if d.wb.calc.mode == gridcraft_engine::model::CalcMode::Manual {
ui.add_space(12.0);
ui.label(egui::RichText::new(app.ui.language.tr("Calculate")).font(theme::ui_font(12.5)).color(t.text_dim));

View File

@ -45,6 +45,11 @@ pub struct Tokens {
pub menu_bg: Color32,
pub shadow: Color32,
pub danger: Color32,
/// Info-bar (notice strip) fills, by severity.
pub notice_warn_bg: Color32,
pub notice_warn_border: Color32,
pub notice_info_bg: Color32,
pub notice_info_border: Color32,
}
impl Tokens {
@ -83,6 +88,10 @@ impl Tokens {
menu_bg: Color32::WHITE,
shadow: Color32::from_black_alpha(40),
danger: Color32::from_rgb(0xC4, 0x2B, 0x1C),
notice_warn_bg: Color32::from_rgb(0xFF, 0xF4, 0xCE),
notice_warn_border: Color32::from_rgb(0xF0, 0xC3, 0x6E),
notice_info_bg: Color32::from_rgb(0xEA, 0xF2, 0xFB),
notice_info_border: Color32::from_rgb(0xB9, 0xD2, 0xEC),
}
}
@ -121,6 +130,10 @@ impl Tokens {
menu_bg: Color32::from_rgb(0x30, 0x30, 0x30),
shadow: Color32::from_black_alpha(90),
danger: Color32::from_rgb(0xF1, 0x70, 0x5F),
notice_warn_bg: Color32::from_rgb(0x4A, 0x3F, 0x1E),
notice_warn_border: Color32::from_rgb(0x7A, 0x67, 0x30),
notice_info_bg: Color32::from_rgb(0x24, 0x33, 0x44),
notice_info_border: Color32::from_rgb(0x3B, 0x55, 0x72),
}
}
@ -146,7 +159,16 @@ pub fn ui_bold(size: f32) -> FontId {
}
/// The font family to use for a cell font name and weight.
///
/// When the name is an installed font whose faces were bound by [`font_definitions`], that family
/// is used; otherwise the name is bucketed to one of the built-in roles (mono/serif/cell). egui
/// panics on a family that was never bound, so the installed branch is gated on
/// [`crate::fonts::is_bound`] and never names a family egui hasn't been given.
pub fn cell_family(name: &str, bold: bool, italic: bool) -> FontFamily {
let role = crate::fonts::role_key(name, bold, italic);
if crate::fonts::is_bound(&role) {
return FontFamily::Name(role.into());
}
let lower = name.to_ascii_lowercase();
if lower.contains("courier") || lower.contains("mono") || lower.contains("consolas") {
return FontFamily::Name(MONO.into());
@ -357,9 +379,17 @@ impl HanOrder {
/// Builds the font set with the Han fallback faces ordered for the interface language.
pub fn font_definitions_for_language(language: crate::i18n::Language) -> FontDefinitions {
font_definitions_with(language, &[])
}
/// [`font_definitions_for_language`] that also binds the workbook fonts in `used` that are
/// installed, so cells set in them render with them; the rest fall back to the roles. The bound
/// families are recorded in [`crate::fonts`] so [`cell_family`] only names a family egui has.
pub fn font_definitions_with(language: crate::i18n::Language, used: &[String]) -> FontDefinitions {
let mut fonts = FontDefinitions::default();
let base_prop: Vec<String> = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
let base_mono: Vec<String> = fonts.families.get(&FontFamily::Monospace).cloned().unwrap_or_default();
let mut bound: Vec<String> = Vec::new();
// The non-Latin fallback faces have to sit at the end of each chain: a Latin font owns Latin,
// and epaint picks the first face that has the glyph, so they only catch their own scripts.
let fallback = load_fallback(&mut fonts, HanOrder::of(language));
@ -380,12 +410,34 @@ pub fn font_definitions_for_language(language: crate::i18n::Language) -> FontDef
chain.extend(fallback.iter().cloned());
chain.retain(|k| fonts.font_data.contains_key(k));
fonts.families.insert(FontFamily::Name(role.into()), chain);
bound.push(role.to_string());
}
// Installed workbook fonts, so a cell's chosen font actually renders in it.
for family in crate::fonts::installed_among(used) {
bound.extend(crate::fonts::bind_family(&mut fonts, &family));
}
// Interface text (formula bar, name box, dialogs) falls back to the cell font before the
// script faces: whatever a cell can draw, the formula bar can draw too, even where no script
// fallback face was found (#349: macOS UI font without Hebrew, cells in Arial with it).
let cell_key = format!("sys-{CELL}");
if fonts.font_data.contains_key(&cell_key) {
for role in [UI, UI_BOLD, SERIF, MONO] {
if let Some(chain) = fonts.families.get_mut(&FontFamily::Name(role.into()))
&& !chain.contains(&cell_key)
{
let at = chain.iter().position(|k| fallback.contains(k)).unwrap_or(chain.len());
chain.insert(at, cell_key.clone());
}
}
}
// Default proportional text in widgets uses the UI font.
if let Some(ui) = fonts.families.get(&FontFamily::Name(UI.into())).cloned() {
fonts.families.insert(FontFamily::Proportional, ui);
}
drop_unparseable_fonts(&mut fonts);
// Only families egui still has data for count as bound (a dropped face can empty a chain).
bound.retain(|f| fonts.families.get(&FontFamily::Name(f.as_str().into())).is_some_and(|c| !c.is_empty()));
crate::fonts::set_bound(bound);
fonts
}
@ -556,8 +608,11 @@ fn hebrew() -> Vec<(String, u32)> {
v.push((format!("{win}\\david.ttf"), 0)); // David
v.push((format!("{win}\\frank.ttf"), 0)); // FrankRuehl
v.push((format!("{win}\\tahoma.ttf"), 0)); // Tahoma
v.push((format!("{mac_sys}/ArialHB.ttc"), 0)); // Arial Hebrew
v.push((format!("{mac_sys}/SFHebrew.ttf"), 0));
v.push((format!("{mac_sup}/Arial Hebrew.ttf"), 0));
v.push((format!("{mac_sup}/David.ttf"), 0));
v.push((format!("{mac_sys}/LucidaGrande.ttc"), 0));
v.push((format!("{mac_sys}/Lucida Grande.ttc"), 0));
for d in lin {
v.push((format!("{d}/NotoSansHebrew-Regular.ttf"), 0));
@ -662,6 +717,54 @@ pub fn color32(rgb: [u8; 3]) -> Color32 {
Color32::from_rgb(rgb[0], rgb[1], rgb[2])
}
#[cfg(test)]
mod font_binding_tests {
use super::*;
/// The families [`font_definitions`] always binds, whatever is installed.
fn always_bound() -> Vec<String> {
[UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO].iter().map(|s| s.to_string()).collect()
}
#[test]
fn a_name_that_is_not_an_installed_font_gets_a_built_in_role() {
let _g = crate::fonts::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
// Nothing installed: every name buckets to a role that font_definitions bound.
crate::fonts::set_bound(always_bound());
for name in ["", "Calibri", "Courier New", "Times New Roman", "Grossly Missing Font", "\u{1F600}"] {
for (b, i) in [(false, false), (true, false), (false, true), (true, true)] {
let FontFamily::Name(fam) = cell_family(name, b, i) else { panic!("{name:?} should name a family") };
assert!(crate::fonts::is_bound(&fam), "{name:?} {b} {i} → unbound {fam:?}");
}
}
}
#[test]
fn a_bound_installed_font_is_used_by_name() {
let _g = crate::fonts::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let role = crate::fonts::role_key("Ubuntu", true, false);
crate::fonts::set_bound(always_bound().into_iter().chain([role.clone()]));
assert!(matches!(cell_family("Ubuntu", true, false), FontFamily::Name(f) if *f == *role));
// The same family in a style that wasn't bound falls back to a built-in role that is.
let FontFamily::Name(fallback) = cell_family("Ubuntu", false, true) else { panic!("should name a family") };
assert!(crate::fonts::is_bound(&fallback), "the fallback is a bound role");
crate::fonts::set_bound(always_bound());
}
#[test]
fn arbitrary_names_never_yield_an_unbound_family() {
let _g = crate::fonts::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
// A workbook can hold any font name; none may name a family egui has no data for.
crate::fonts::set_bound(always_bound());
for seed in 0..500u32 {
let name: String =
(0..(seed % 40)).map(|k| char::from_u32(0x20 + (seed.wrapping_mul(31).wrapping_add(k) % 0x5F)).unwrap_or('x')).collect();
let FontFamily::Name(fam) = cell_family(&name, seed % 2 == 0, seed % 3 == 0) else { continue };
assert!(crate::fonts::is_bound(&fam), "{name:?} → unbound {fam:?}");
}
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
@ -688,6 +791,25 @@ mod tests {
}
}
#[test]
fn interface_families_fall_back_to_the_cell_font() {
// Structural (#349): the formula bar, name box and dialogs draw every script the cells can,
// so each interface chain carries the cell face, ahead of the script fallback faces.
let fonts = font_definitions_for_language(Language::En);
let cell = format!("sys-{CELL}");
if !fonts.font_data.contains_key(&cell) {
return; // no system cell font on this machine
}
let fb: Vec<String> = fallback_faces(HanOrder::Chinese).into_iter().map(|(k, _)| k.to_string()).collect();
for family in [FontFamily::Proportional, FontFamily::Name(UI.into()), FontFamily::Name(UI_BOLD.into()), FontFamily::Name(MONO.into())] {
let chain = fonts.families.get(&family).cloned().unwrap_or_default();
let at = chain.iter().position(|k| *k == cell);
assert!(at.is_some(), "{family:?} lacks the cell face: {chain:?}");
let first_fb = chain.iter().position(|k| fb.contains(k)).unwrap_or(chain.len());
assert!(at.unwrap_or(usize::MAX) < first_fb, "{family:?}: cell face must precede the script faces");
}
}
#[test]
fn language_reorders_only_the_han_faces() {
let keys = |han| fallback_faces(han).into_iter().map(|(k, _)| k).collect::<Vec<_>>();

View File

@ -236,3 +236,51 @@ pub fn toast(app: &mut SheetApp, ctx: &egui::Context) {
);
ctx.request_repaint_after(std::time::Duration::from_millis(200));
}
/// The info bar under the ribbon (Excel's message-bar slot). Shows an open-time notice with an
/// optional action button and a dismiss ✕. Returns nothing; dismissing clears `app.notice`.
pub fn notice_bar(app: &mut SheetApp, ui: &mut Ui) {
let Some(notice) = app.notice.clone() else { return };
let t = Tokens::get(ui.ctx());
let (bg, border) = match notice.kind {
crate::NoticeKind::Warning => (t.notice_warn_bg, t.notice_warn_border),
crate::NoticeKind::Info => (t.notice_info_bg, t.notice_info_border),
};
let mut dismiss = false;
let mut action: Option<(String, String)> = None;
egui::Panel::top("notice_bar").frame(egui::Frame::NONE.fill(bg).inner_margin(egui::Margin::symmetric(12, 0))).show(ui, |ui| {
// Bottom border as a 1px strip spanning the bar.
let bar = ui.max_rect();
ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, border), StrokeKind::Inside);
ui.horizontal_centered(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
icons::paint(
ui.painter(),
r,
match notice.kind {
crate::NoticeKind::Warning => Icon::Lock,
crate::NoticeKind::Info => Icon::Note,
},
border,
);
ui.add_space(6.0);
ui.label(egui::RichText::new(&notice.text).font(theme::ui_font(12.5)).color(t.text));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if icon_button(ui, Icon::Close, t.text_dim, "Dismiss", vec2(20.0, 20.0)).clicked() {
dismiss = true;
}
if let Some((label, dialog)) = &notice.action
&& ui.button(egui::RichText::new(label).font(theme::ui_font(12.5))).clicked()
{
action = Some((label.clone(), dialog.clone()));
}
});
});
});
if let Some((_, dialog)) = action {
app.open_dialog(&dialog, serde_json::json!({}));
}
if dismiss {
app.notice = None;
}
}

View File

@ -127,3 +127,36 @@ fn border_preview_uses_the_colored_draft_until_ok_and_cancel_discards_it() {
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(borders(&h, "A1"), original);
}
/// #376: a merged range's outline lives on the cells along each edge (the right edge on the last
/// column's cells, the bottom on the last row's), not on the anchor alone. All four sides are drawn.
#[test]
fn merged_range_draws_the_outline_from_its_edge_cells() {
let mut h = harness();
h.state_mut().run("home.borders", json!({"range": "B2:E5", "preset": "thickOutside", "color": "#C81E1E"})).unwrap();
h.state_mut().run("home.mergeCells", json!({"range": "B2:E5"})).unwrap();
h.state_mut().run("selection.set", json!({"range": "H10"})).unwrap();
h.run_steps(6);
assert!(borders(&h, "B2").right.is_none(), "the anchor carries only the top and left edges");
let red = Color32::from_rgb(0xC8, 0x1E, 0x1E);
let segs: Vec<[egui::Pos2; 2]> = h
.output()
.shapes
.iter()
.filter_map(|shape| match &shape.shape {
Shape::LineSegment { points, stroke } if stroke.color == red => Some(*points),
_ => None,
})
.collect();
let vertical = |s: &[egui::Pos2; 2]| (s[0].x - s[1].x).abs() < 0.5;
let xs: Vec<f32> = segs.iter().filter(|s| vertical(s)).map(|s| s[0].x).collect();
let ys: Vec<f32> = segs.iter().filter(|s| !vertical(s)).map(|s| s[0].y).collect();
let (min_x, max_x) = xs.iter().fold((f32::MAX, f32::MIN), |(a, b), x| (a.min(*x), b.max(*x)));
let (min_y, max_y) = ys.iter().fold((f32::MAX, f32::MIN), |(a, b), y| (a.min(*y), b.max(*y)));
// Left and right edges (four rows each), top and bottom edges (four columns each).
assert_eq!(xs.iter().filter(|x| (**x - min_x).abs() < 0.5).count(), 4, "left edge");
assert_eq!(xs.iter().filter(|x| (**x - max_x).abs() < 0.5).count(), 4, "right edge");
assert_eq!(ys.iter().filter(|y| (**y - min_y).abs() < 0.5).count(), 4, "top edge");
assert_eq!(ys.iter().filter(|y| (**y - max_y).abs() < 0.5).count(), 4, "bottom edge");
assert!(max_x - min_x > 100.0 && max_y - min_y > 40.0, "the outline spans the whole merged area");
}

View File

@ -146,3 +146,28 @@ fn export_as_pdf_is_delivered_as_a_download() {
assert!(got[0].0.ends_with(".pdf"));
assert!(got[0].1.starts_with(b"%PDF-"));
}
#[test]
fn save_as_offers_pdf_and_leaves_the_workbook_file_alone() {
let dir = std::env::temp_dir().join(format!("gridcraft-save-as-type-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let asked = Arc::new(Mutex::new(Vec::<String>::new()));
let (capture, target) = (asked.clone(), dir.clone());
let mut h = harness(Services {
pick_save: Some(Box::new(move |suggested| {
capture.lock().unwrap().push(suggested.to_string());
Some(target.join("Report").to_string_lossy().into_owned())
})),
..Default::default()
});
h.state_mut().run("cell.set", serde_json::json!({"cell": "A1", "input": "42"})).unwrap();
choose(&mut h, "Save As");
h.state_mut().backstage.as_mut().unwrap().save_format = "pdf";
h.run_steps(2);
click(&mut h, "Browse…");
assert!(asked.lock().unwrap().first().is_some_and(|s| s.ends_with(".pdf")), "the picker is offered a .pdf name");
assert!(std::fs::read(dir.join("Report.pdf")).unwrap().starts_with(b"%PDF-"));
let d = h.state().session.active().unwrap();
assert!(d.path.is_none() && d.is_dirty(), "a PDF is a copy, not the workbook's file");
let _ = std::fs::remove_dir_all(&dir);
}

View File

@ -0,0 +1,65 @@
//! Whole-column reordering driven through real egui pointer events.
use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2};
use gridcraft_engine::{Session, core::CellRef};
use gridcraft_ui_egui::{SheetApp, grid::Geo};
use serde_json::json;
fn harness() -> egui_kittest::Harness<'static, SheetApp> {
let mut session = Session::new();
session.new_workbook();
session.execute("range.setValues", json!({"range": "A1", "values": [["A", "B", "C", "D", "E"]]})).unwrap();
session.execute("selection.set", json!({"range": "B:C"})).unwrap();
let mut harness = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SheetApp::new(session, Default::default()),
);
harness.run_steps(4);
harness.state_mut().session.active_mut().unwrap().undo.clear();
harness.state_mut().session.journal.clear();
harness
}
fn header_center(harness: &egui_kittest::Harness<'_, SheetApp>, col: u32) -> Pos2 {
let app = harness.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll);
pos2(geo.x(sheet, col) + sheet.col_width(col) * geo.z * 0.5, geo.rect.top() + geo.header_h * 0.5)
}
fn pointer_button(harness: &mut egui_kittest::Harness<'_, SheetApp>, pos: Pos2, pressed: bool) {
harness
.input_mut()
.events
.extend([Event::PointerMoved(pos), Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]);
harness.step();
}
#[test]
fn dragging_selected_column_headers_reorders_the_block() {
let mut harness = harness();
let start = header_center(&harness, 1);
let target = {
let center = header_center(&harness, 4);
pos2(center.x - 20.0, center.y)
};
pointer_button(&mut harness, start, true);
harness.input_mut().events.push(Event::PointerMoved(target));
harness.step();
harness.input_mut().events.push(Event::PointerMoved(target + vec2(1.0, 0.0)));
harness.step();
pointer_button(&mut harness, target + vec2(1.0, 0.0), false);
harness.run_steps(2);
let app = harness.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
let values: Vec<_> = (0..5).map(|col| sheet.value(CellRef::new(0, col))).collect();
assert_eq!(values, vec!["A".into(), "D".into(), "B".into(), "C".into(), "E".into()]);
assert_eq!(app.session.active().unwrap().selection.current().a1(), "C:D");
assert_eq!(app.session.journal.iter().filter(|(id, _)| id == "sheet.moveColumns").count(), 1);
assert_eq!(app.session.active().unwrap().undo.len(), 1);
}

View File

@ -384,6 +384,7 @@ fn dialogs_open_and_close() {
"goTo",
"pivotCalculatedField",
"about",
"macros",
"analysisToolPak",
"solver",
] {
@ -484,6 +485,90 @@ fn column_autocomplete_completes_on_enter() {
assert_eq!(value(&h, "A5"), Value::from("Ea"));
}
/// An .xlsm carrying a VBA project, saved to bytes.
fn macro_workbook_bytes() -> Vec<u8> {
let mut wb = gridcraft_engine::model::Workbook::new();
wb.vba_project = Some(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1 fake OLE2 vbaProject".to_vec());
gridcraft_engine::io::save_bytes(&wb, "macros.xlsm").unwrap()
}
#[test]
fn opening_a_macro_workbook_shows_a_notice() {
// The reader's "macros kept but not executed" warning must reach a dismissible info bar,
// not be silently dropped (issue #6 follow-up: Excel-style message bar).
let mut h = harness(blank());
let b64 = gridcraft_engine::io::base64_encode(&macro_workbook_bytes());
let res = h.state_mut().session.run("file.open", json!({"name": "macros.xlsm", "base64": b64})).unwrap();
assert!(
res.get("warnings").and_then(|w| w.as_array()).is_some_and(|a| a.iter().any(|m| m.as_str().is_some_and(|s| s.contains("macros")))),
"engine reports the macro warning: {res}"
);
// The UI-side handler turns that open result into the notice (open_path does this in the app).
h.state_mut().show_open_warnings(&res);
let n = h.state().notice.clone().expect("a notice was shown");
assert!(n.text.contains("VBA"), "{}", n.text);
assert_eq!(n.kind, gridcraft_ui_egui::NoticeKind::Warning);
assert_eq!(n.action.as_ref().map(|a| a.1.as_str()), Some("macros"), "action opens the Macros dialog");
// The notice bar renders without panic; `ui.notice` surfaces it; dismissing clears it.
h.run_steps(2);
let got = h.state_mut().run("ui.notice", json!({})).unwrap();
assert_eq!(got.get("kind").and_then(|k| k.as_str()), Some("warning"));
h.state_mut().run("ui.notice.dismiss", json!({})).unwrap();
h.run_steps(2);
assert!(h.state().notice.is_none());
}
#[test]
fn plain_open_warning_becomes_an_info_notice() {
let mut h = harness(blank());
h.state_mut().show_open_warnings(&json!({"warnings": ["links to external workbooks are not supported"]}));
let n = h.state().notice.clone().unwrap();
assert_eq!(n.kind, gridcraft_ui_egui::NoticeKind::Info);
assert!(n.action.is_none());
assert!(n.text.contains("external"));
// No warnings → no notice, no panic.
h.state_mut().show_open_warnings(&json!({"warnings": []}));
assert!(h.state().notice.is_none());
}
#[test]
fn macros_dialog_lists_vba_and_scripts() {
let mut s = blank();
let mut wb = gridcraft_engine::model::Workbook::new();
wb.vba_project = Some(vec![0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]);
s.active_mut().unwrap().wb = std::sync::Arc::new(wb);
let mut h = harness(s);
h.state_mut().open_dialog("macros", json!({}));
h.run_steps(3);
let d = h.state().dialog.as_ref().expect("macros dialog open");
assert_eq!(d.name(), "macros");
let items = d.result.as_ref().and_then(|r| r.as_array()).cloned().unwrap_or_default();
assert!(items.iter().any(|i| i.get("kind").and_then(|k| k.as_str()) == Some("vba")), "{items:?}");
h.state_mut().dialog = None;
}
#[test]
fn status_bar_shows_macro_badge() {
let mut s = blank();
let mut wb = gridcraft_engine::model::Workbook::new();
wb.vba_project = Some(vec![0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]);
s.active_mut().unwrap().wb = std::sync::Arc::new(wb);
// Rendering the status bar must not panic with a VBA project present.
let mut h = harness(s);
h.run_steps(3);
h.state_mut().ui.dark = true;
h.run_steps(2);
}
#[test]
fn alt_f8_opens_the_macros_dialog() {
let mut h = harness(blank());
key(&mut h, Key::F8, Modifiers::ALT);
assert_eq!(h.state().dialog.as_ref().map(|d| d.name().to_string()).as_deref(), Some("macros"));
h.state_mut().dialog = None;
}
#[test]
fn general_numbers_fit_by_the_cells_own_font() {
// 9pt marks in narrow columns (a stored width of 4, i.e. 28px; common in mark sheets): "100" fits.

View File

@ -102,7 +102,50 @@ fn windows_and_mac_variants() {
lookup(Key::Exclamationmark, Some(Key::Num1), CTRL_SHIFT, false),
Some(Shortcut::Run("home.numberFormat", json!({"code": "#,##0.00"})))
);
// Navigation keys go by what they are, not where: the numpad's arrows and Page Down (Num
// Lock off) report Num6 / Num3 as their physical keys.
let alt_shift = Modifiers { alt: true, shift: true, ..Modifiers::NONE };
assert_eq!(lookup(Key::ArrowRight, Some(Key::Num6), alt_shift, false), run("data.group"));
assert_eq!(lookup(Key::ArrowLeft, Some(Key::Num4), alt_shift, false), run("data.ungroup"));
assert_eq!(lookup(Key::PageDown, Some(Key::Num3), Modifiers::ALT, false), Some(Shortcut::ScreenRight));
assert_eq!(lookup(Key::PageDown, Some(Key::Num3), CTRL_SHIFT, false), None);
// Plain keys stay with the grid (typing, navigation).
assert_eq!(lookup(Key::A, Some(Key::A), Modifiers::NONE, false), None);
assert_eq!(lookup(Key::F2, Some(Key::F2), Modifiers::NONE, false), None);
}
#[test]
fn space_selection_shortcuts_do_not_start_an_edit() {
let mut h = harness(book("B2"));
// Shift+Space: the key press, then the space the OS types with it.
let shift = Modifiers::SHIFT;
h.input_mut().events.push(Event::Key { key: Key::Space, physical_key: Some(Key::Space), pressed: true, repeat: false, modifiers: shift });
h.input_mut().events.push(Event::Text(" ".into()));
h.run_steps(2);
assert!(h.state().editor.is_none(), "Shift+Space started an edit");
assert_eq!(h.state().session.active().unwrap().selection.ranges[0].a1(), "2:2");
h.state_mut().session.run("selection.set", json!({"range": "B2"})).unwrap();
// Ctrl+Space selects the column, also without starting an edit.
h.input_mut().events.push(Event::Key { key: Key::Space, physical_key: Some(Key::Space), pressed: true, repeat: false, modifiers: CTRL });
h.input_mut().events.push(Event::Text(" ".into()));
h.run_steps(2);
assert!(h.state().editor.is_none());
assert_eq!(h.state().session.active().unwrap().selection.ranges[0].a1(), "B:B");
// Plain typing still edits.
h.input_mut().events.push(Event::Text("x".into()));
h.run_steps(2);
assert!(h.state().editor.is_some());
}
#[test]
fn space_shortcuts_per_platform() {
let run = |id: &'static str| Some(Shortcut::Run(id, json!({})));
let mac_cmd = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE };
let space = |m, mac| lookup(Key::Space, Some(Key::Space), m, mac);
assert_eq!(space(Modifiers::SHIFT, false), run("selection.row"));
assert_eq!(space(Modifiers::SHIFT, true), run("selection.row"));
assert_eq!(space(CTRL, false), run("selection.column"));
assert_eq!(space(Modifiers::CTRL, true), run("selection.column"));
// Cmd+Space is Spotlight on macOS.
assert_eq!(space(mac_cmd, true), None);
}

View File

@ -0,0 +1,92 @@
//! The outline band of grouped rows and columns: − / + and level buttons (#373).
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
use gridcraft_engine::Session;
use gridcraft_ui_egui::SheetApp;
use gridcraft_ui_egui::outline::{Action, layout};
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn harness() -> Harness {
let mut session = Session::new();
session.new_workbook();
let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SheetApp::new(session, Default::default()),
);
h.run_steps(4);
h
}
fn click(h: &mut Harness, pos: Pos2) {
let press = |pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
h.input_mut().events.extend([Event::PointerMoved(pos), press(true)]);
h.step();
h.input_mut().events.push(press(false));
h.run_steps(3);
}
/// Where the button that does `which` is drawn this frame (screen coordinates).
fn button(h: &Harness, which: impl Fn(&Action) -> bool) -> Pos2 {
let app = h.state();
let geo = app.grid.geo.clone().unwrap();
let sh = app.session.active().unwrap().wb.active().unwrap().clone();
let b = layout(&geo, &sh).buttons.into_iter().find(|b| which(&b.action)).expect("button on screen");
geo.mirror_pos(b.rect.center())
}
fn hidden_rows(h: &Harness) -> Vec<u32> {
let sh = h.state().session.active().unwrap().wb.active().unwrap().clone();
sh.rows.iter().filter(|(_, l)| l.hidden).map(|(r, _)| *r).collect()
}
fn toggle(rows: bool, level: u8) -> impl Fn(&Action) -> bool {
move |a| matches!(a, Action::Toggle { rows: r, group } if *r == rows && group.level == level)
}
fn outline_buttons_collapse_and_expand(rtl: bool) {
let mut h = harness();
if rtl {
h.state_mut().session.run("view.rightToLeft", json!({"on": true})).unwrap();
}
h.state_mut().session.run("data.group", json!({"rows": "5:8"})).unwrap();
h.state_mut().session.run("data.group", json!({"cols": "C:E"})).unwrap();
h.run_steps(2);
let geo = h.state().grid.geo.clone().unwrap();
assert!(geo.band_w > 0.0 && geo.band_h > 0.0);
// − on row 9 hides rows 5:8 and turns into +.
let minus = button(&h, toggle(true, 1));
click(&mut h, minus);
assert_eq!(hidden_rows(&h), [4, 5, 6, 7]);
let plus = button(&h, toggle(true, 1));
click(&mut h, plus);
assert!(hidden_rows(&h).is_empty());
// Level 1 for the columns collapses C:E; level 2 shows them again.
let level1 = button(&h, |a| *a == Action::Level { rows: false, level: 1 });
click(&mut h, level1);
let col_hidden = |h: &Harness| (2..=4).all(|c| h.state().session.active().unwrap().wb.active().unwrap().col_width(c) == 0.0);
assert!(col_hidden(&h));
let level2 = button(&h, |a| *a == Action::Level { rows: false, level: 2 });
click(&mut h, level2);
assert!(!col_hidden(&h));
// Every click was a command: undo walks them back.
h.state_mut().session.run("edit.undo", json!({})).unwrap();
assert!(col_hidden(&h));
}
#[test]
fn outline_buttons_collapse_expand_and_undo() {
outline_buttons_collapse_and_expand(false);
}
#[test]
fn outline_buttons_work_on_right_to_left_sheets() {
outline_buttons_collapse_and_expand(true);
}

View File

@ -124,3 +124,41 @@ fn header_is_edited_in_place_committed_by_a_click_elsewhere_and_undoable() {
assert!(h.state().grid.hf_edit.is_none());
assert_eq!(header(&h), "&LLeft");
}
#[test]
fn the_first_page_edits_its_own_header_when_different_first_page_is_on() {
let mut h = fixture();
h.state_mut().run("pageLayout.headerFooter", json!({"differentFirst": true, "firstHeader": "&CCover"})).unwrap();
h.run_steps(3);
// Insert › Header & Footer edits the first page's centre header, here the first-page one.
gridcraft_ui_egui::paper_view::start_editing(h.state_mut());
h.run_steps(3);
{
let e = h.state().grid.hf_edit.as_ref().expect("Insert › Header & Footer starts editing");
assert_eq!((e.footer, e.section, e.kind), (false, 1, gridcraft_engine::model::HfPage::First));
assert_eq!(e.texts[1], "Cover");
}
h.input_mut().events.push(Event::Text(" page".into()));
h.run_steps(2);
let g = geo(&h);
let b3 = {
let app = h.state();
g.cell_rect(app.session.doc().unwrap().wb.active().unwrap(), CellRef::parse("B3").unwrap()).center()
};
click(&mut h, b3);
let pr = h.state().session.doc().unwrap().wb.active().unwrap().print.clone();
assert_eq!(pr.first_header, "&CCover page");
assert_eq!(pr.header, "&LLeft", "the other pages keep their header");
}
#[test]
fn insert_header_and_footer_switches_to_page_layout_view() {
let mut h = fixture();
h.state_mut().run("view.normal", json!({})).unwrap();
h.run_steps(3);
gridcraft_ui_egui::paper_view::start_editing(h.state_mut());
h.run_steps(4);
assert_eq!(h.state().session.doc().unwrap().wb.active().unwrap().view, SheetViewMode::PageLayout);
let e = h.state().grid.hf_edit.as_ref().expect("the header is being edited");
assert_eq!((e.footer, e.section, e.texts[0].as_str()), (false, 1, "Left"));
}

View File

@ -57,7 +57,7 @@ fn protect_and_unprotect_sheet_mask_the_password() {
h.get_by_label(" OK ").click();
h.run_steps(3);
assert!(h.state().dialog.is_none());
assert!(h.state().session.active().unwrap().wb.active().unwrap().protection.as_ref().is_some_and(|p| p.password_hash.is_some()));
assert!(h.state().session.active().unwrap().wb.active().unwrap().protection.as_ref().is_some_and(|p| p.password.is_some()));
// Unprotect asks for the password with a masked field too, and it starts hidden again.
h.state_mut().open_dialog("unprotectSheet", json!({}));

View File

@ -0,0 +1,60 @@
//! The saved scroll position (`sheetView topLeftCell`) is restored on open and saved back (#378).
use gridcraft_engine::core::{CellRef, Value};
use gridcraft_engine::model::Workbook;
use gridcraft_engine::{DocState, Session};
use gridcraft_ui_egui::{SheetApp, grid::Geo};
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn harness(freeze: Option<(u32, u32)>) -> Harness {
let mut wb = Workbook::new();
let sh = wb.sheet_mut(0).unwrap();
for r in 0..60 {
sh.set_value(CellRef::new(r, 0), Value::text(format!("row {}", r + 1)));
}
sh.view_top_left = CellRef::parse("A31").unwrap();
sh.view_active = CellRef::parse("A31").unwrap();
sh.freeze = freeze;
let mut session = Session::new();
session.add_document(DocState::new(wb, None, "Book1".into()));
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SheetApp::new(session, Default::default()),
);
h.run_steps(4);
h
}
/// The first row painted at the top of the scrolling area.
fn first_scrolled_row(h: &Harness) -> u32 {
let app = h.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll);
let (fr, _) = sheet.freeze.unwrap_or((0, 0));
let top = geo.cells.top() + sheet.row_top(fr) as f32 * geo.z;
(fr..200).find(|r| geo.y(sheet, *r) <= top + 0.5 && geo.y(sheet, *r + 1) > top + 0.5).unwrap()
}
#[test]
fn saved_scroll_position_is_restored_and_saved_back() {
for freeze in [None, Some((1, 0))] {
let mut h = harness(freeze);
assert_eq!(first_scrolled_row(&h), 30, "row 31 is the first visible row ({freeze:?})");
// Scroll to row 51 and save: the file gets the new position, the document stays clean.
{
let app = h.state_mut();
let sh = app.session.active().unwrap().wb.active().unwrap().clone();
let fr = freeze.map_or(0, |f| f.0);
app.view_mut().unwrap().scroll.y = (sh.row_top(50) - sh.row_top(fr)) as f32;
app.run("file.saveBytes", json!({"format": "xlsx"})).unwrap();
}
let d = h.state().session.active().unwrap();
assert_eq!(d.wb.active().unwrap().view_top_left, CellRef::parse("A51").unwrap());
assert!(!d.is_dirty(), "scrolling and saving view state does not dirty the workbook");
}
}

View File

@ -0,0 +1,61 @@
//! Shrink to fit (#375): too-wide content is drawn in a smaller font inside its cell.
use egui::Shape;
use gridcraft_engine::Session;
use gridcraft_engine::core::CellRef;
use gridcraft_ui_egui::{SheetApp, grid::Geo};
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn harness() -> Harness {
let mut s = Session::new();
s.new_workbook();
let shrink = json!({"align": {"shrink": true}});
s.execute("cell.set", json!({"cell": "A1", "input": "Executive Producer"})).unwrap();
s.execute("home.formatCells", json!({"range": "A1", "style": shrink})).unwrap();
s.execute("cell.set", json!({"cell": "A2", "input": "Control Producer"})).unwrap();
s.execute("cell.set", json!({"cell": "A4", "input": "123456789012"})).unwrap();
s.execute("home.formatCells", json!({"range": "A4", "style": {"num_fmt": "#,##0", "align": {"shrink": true}}})).unwrap();
s.execute("cell.set", json!({"cell": "A5", "input": "123456789012"})).unwrap();
s.execute("home.formatCells", json!({"range": "A5", "style": {"num_fmt": "#,##0"}})).unwrap();
s.execute("selection.set", json!({"range": "H20"})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SheetApp::new(s, Default::default()),
);
h.run_steps(4);
h
}
/// The painted text shape whose text is `text`: (font size, width, left x).
fn text_shape(h: &Harness, text: &str) -> Option<(f32, f32, f32)> {
h.output().shapes.iter().find_map(|s| match &s.shape {
Shape::Text(t) if t.galley.job.text == text => {
let size = t.galley.job.sections.first().map(|s| s.format.font_id.size)?;
Some((size, t.galley.size().x, t.pos.x))
}
_ => None,
})
}
#[test]
fn shrink_to_fit_text_and_numbers_fit_their_cell() {
let h = harness();
let app = h.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll);
let a1 = geo.cell_rect(sheet, CellRef::parse("A1").unwrap());
let (small, w, x) = text_shape(&h, "Executive Producer").expect("A1 is drawn");
let (normal, cw, _) = text_shape(&h, "Control Producer").expect("A2 is drawn");
assert!(small < normal, "A1 is drawn smaller ({small} vs {normal})");
assert!(cw > a1.width(), "the control is wider than the column");
assert!(x >= a1.left() && x + w <= a1.right(), "A1 fits inside column A");
// A number shrinks instead of turning into ####; its unshrunk twin still shows ####.
let (num, nw, _) = text_shape(&h, "123,456,789,012").expect("A4 shows the number");
assert!(num < normal && nw <= a1.width());
assert!(h.output().shapes.iter().any(|s| matches!(&s.shape, Shape::Text(t) if t.galley.job.text.starts_with("##"))));
}

View File

@ -99,7 +99,7 @@ fn system_tracks_live_os_changes_with_both_gridcraft_palettes_and_light_fallback
system_theme(&mut h, reported);
assert_appearance(&h, dark);
assert_eq!(preferences(&h)["systemTheme"], true);
assert_eq!(h.ctx.options(|o| o.theme_preference), ThemePreference::System);
assert_eq!(h.ctx.theme(), if dark { Theme::Dark } else { Theme::Light });
}
}
@ -112,7 +112,7 @@ fn ribbon_selects_system_and_fixed_modes_ignore_subsequent_os_changes() {
h.run_steps(2);
select_ribbon_theme(&mut h, "System");
assert_eq!(preferences(&h)["systemTheme"], true);
assert_eq!(h.ctx.options(|o| o.theme_preference), ThemePreference::System);
assert_eq!(h.ctx.theme(), Theme::Dark);
assert_appearance(&h, true);
for (label, dark, preference) in [("Dark", true, ThemePreference::Dark), ("Light", false, ThemePreference::Light)] {
select_ribbon_theme(&mut h, label);

View File

@ -97,6 +97,222 @@ fn directory_names(b: &[u8]) -> Option<Vec<String>> {
Some(names)
}
/// Directory entry type of a stream object ([MS-CFB] 2.6.1).
const STREAM_OBJECT: u8 = 2;
/// "No entry" in the directory tree (NOSTREAM).
const NO_STREAM: u32 = 0xFFFF_FFFF;
/// Streams larger than this are refused (the format allows more, a workbook never needs it).
const MAX_STREAM: u64 = 1 << 30;
/// One directory entry ([MS-CFB] 2.6).
struct DirEntry {
name: String,
kind: u8,
left: u32,
right: u32,
child: u32,
start: u32,
size: u64,
}
/// A parsed compound file: the FAT, the mini FAT and the directory, all bounds-checked.
/// Implemented from Microsoft's public [MS-CFB] specification.
pub(crate) struct Compound<'a> {
bytes: &'a [u8],
sector_size: usize,
mini_sector_size: usize,
mini_cutoff: u64,
fat: Vec<u32>,
mini_fat: Vec<u32>,
entries: Vec<DirEntry>,
}
impl<'a> Compound<'a> {
/// Parses the header, FAT, mini FAT and directory. Errors on anything that can't be followed.
pub(crate) fn open(b: &'a [u8]) -> Result<Compound<'a>, String> {
let bad = |m: &str| format!("damaged compound file: {m}");
if !b.starts_with(&SIGNATURE) {
return Err(bad("missing signature"));
}
let major = u16_at(b, 26).ok_or_else(|| bad("truncated header"))?;
let shift = u16_at(b, 30).ok_or_else(|| bad("truncated header"))?;
if shift != 9 && shift != 12 {
return Err(bad("unsupported sector size"));
}
let mini_shift = u16_at(b, 32).ok_or_else(|| bad("truncated header"))?;
if mini_shift != 6 {
return Err(bad("unsupported mini sector size"));
}
let sector_size = 1usize << shift;
let per = sector_size / 4;
// Sectors that physically exist after the header sector: every chain is bounded by it.
let sectors = (b.len() / sector_size).saturating_sub(1);
let sector = |id: u32| -> Option<&[u8]> {
let start = (id as usize).checked_add(1)?.checked_mul(sector_size)?;
b.get(start..start.checked_add(sector_size)?)
};
// DIFAT: 109 FAT sector ids in the header, then chained DIFAT sectors.
let mut fat_sectors: Vec<u32> = (0..109).filter_map(|i| u32_at(b, 76 + i * 4)).filter(|&s| s < MAX_REG_SECT).collect();
let mut next = u32_at(b, 68).ok_or_else(|| bad("truncated header"))?;
let mut hops = 0usize;
while next < MAX_REG_SECT {
hops += 1;
if hops > sectors.min(MAX_DIFAT_SECTORS * 64) {
return Err(bad("DIFAT chain loops"));
}
let s = sector(next).ok_or_else(|| bad("DIFAT sector out of range"))?;
fat_sectors.extend((0..per - 1).filter_map(|i| u32_at(s, i * 4)).filter(|&x| x < MAX_REG_SECT));
next = u32_at(s, (per - 1) * 4).ok_or_else(|| bad("truncated DIFAT sector"))?;
}
if fat_sectors.len() > sectors {
return Err(bad("more FAT sectors than the file holds"));
}
let mut fat = Vec::with_capacity(fat_sectors.len().saturating_mul(per));
for id in fat_sectors {
let s = sector(id).ok_or_else(|| bad("FAT sector out of range"))?;
fat.extend((0..per).filter_map(|i| u32_at(s, i * 4)));
}
let mut cf = Compound { bytes: b, sector_size, mini_sector_size: 64, mini_cutoff: 4096, fat, mini_fat: vec![], entries: vec![] };
if let Some(c) = u32_at(b, 56) {
cf.mini_cutoff = u64::from(c);
}
// Directory.
let dir_start = u32_at(b, 48).ok_or_else(|| bad("truncated header"))?;
let dir = cf.read_chain(dir_start, u64::MAX)?;
for e in dir.as_chunks::<128>().0 {
let len = usize::from(u16_at(e, 64).unwrap_or(0)).min(64);
let units: Vec<u16> = (0..len.saturating_sub(2) / 2).filter_map(|i| u16_at(e, i * 2)).collect();
let mut size = u32_at(e, 120).map(u64::from).unwrap_or(0);
if major >= 4 {
size |= u64::from(u32_at(e, 124).unwrap_or(0)) << 32;
}
cf.entries.push(DirEntry {
name: String::from_utf16_lossy(&units),
kind: e.get(66).copied().unwrap_or(0),
left: u32_at(e, 68).unwrap_or(NO_STREAM),
right: u32_at(e, 72).unwrap_or(NO_STREAM),
child: u32_at(e, 76).unwrap_or(NO_STREAM),
start: u32_at(e, 116).unwrap_or(END_OF_CHAIN),
size,
});
}
if cf.entries.first().is_none_or(|r| r.kind != 5) {
return Err(bad("missing root entry"));
}
// Mini FAT.
let mini_start = u32_at(b, 60).ok_or_else(|| bad("truncated header"))?;
if mini_start < MAX_REG_SECT {
let raw = cf.read_chain(mini_start, u64::MAX)?;
cf.mini_fat = (0..raw.len() / 4).filter_map(|i| u32_at(&raw, i * 4)).collect();
}
Ok(cf)
}
/// Follows a FAT chain from `start`, reading at most `limit` bytes. A chain longer than the
/// FAT (so a cycle) or pointing outside the file is an error.
fn read_chain(&self, start: u32, limit: u64) -> Result<Vec<u8>, String> {
let limit = usize::try_from(limit.min(MAX_STREAM)).unwrap_or(usize::MAX);
let mut out = Vec::new();
let mut id = start;
let mut seen = vec![false; self.fat.len()];
while id != END_OF_CHAIN && out.len() < limit {
if id >= MAX_REG_SECT {
return Err("damaged compound file: broken sector chain".into());
}
match seen.get_mut(id as usize) {
Some(v) if !*v => *v = true,
Some(_) => return Err("damaged compound file: sector chain loops".into()),
None => return Err("damaged compound file: sector missing from the FAT".into()),
}
let start = (id as usize).checked_add(1).and_then(|i| i.checked_mul(self.sector_size));
let s = start
.and_then(|s| self.bytes.get(s..s.checked_add(self.sector_size)?))
.ok_or("damaged compound file: sector out of range (the file may be truncated)")?;
out.extend_from_slice(s);
id = *self.fat.get(id as usize).ok_or("damaged compound file: sector missing from the FAT")?;
}
Ok(out)
}
/// Follows a mini FAT chain inside the mini stream container.
fn read_mini_chain(&self, container: &[u8], start: u32, limit: usize) -> Result<Vec<u8>, String> {
let mut out = Vec::new();
let mut id = start;
let mut seen = vec![false; self.mini_fat.len()];
while id != END_OF_CHAIN && out.len() < limit {
if id >= MAX_REG_SECT {
return Err("damaged compound file: broken mini sector chain".into());
}
match seen.get_mut(id as usize) {
Some(v) if !*v => *v = true,
Some(_) => return Err("damaged compound file: mini sector chain loops".into()),
None => return Err("damaged compound file: mini sector missing from the mini FAT".into()),
}
let at = (id as usize).checked_mul(self.mini_sector_size);
let s =
at.and_then(|a| container.get(a..a.checked_add(self.mini_sector_size)?)).ok_or("damaged compound file: mini sector out of range")?;
out.extend_from_slice(s);
id = *self.mini_fat.get(id as usize).ok_or("damaged compound file: mini sector missing from the mini FAT")?;
}
Ok(out)
}
/// The contents of the stream called `name` (case-insensitive) directly under the root
/// storage, or `None` when there is no such stream.
/// The directory entry of the stream called `name` (case-insensitive) directly under the
/// root storage.
fn root_entry(&self, name: &str) -> Result<Option<&DirEntry>, String> {
let Some(root) = self.entries.first() else { return Ok(None) };
// Walk the root's children (a red-black tree through left/right siblings), bounded.
let mut stack = vec![root.child];
let mut seen = 0usize;
while let Some(i) = stack.pop() {
if i == NO_STREAM {
continue;
}
seen += 1;
if seen > self.entries.len() {
return Err("damaged compound file: directory tree loops".into());
}
let Some(e) = self.entries.get(i as usize) else { continue };
if e.kind == STREAM_OBJECT && e.name.eq_ignore_ascii_case(name) {
return Ok(Some(e));
}
stack.push(e.left);
stack.push(e.right);
}
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> {
let Some(root) = self.entries.first() else { return Ok(None) };
let found = self.root_entry(name)?;
let Some(e) = found else { return Ok(None) };
if e.size > MAX_STREAM {
return Err("the workbook stream is larger than 1 GiB".into());
}
let size = usize::try_from(e.size).map_err(|_| "stream too large".to_string())?;
let mut data = if e.size < self.mini_cutoff {
let container = self.read_chain(root.start, root.size)?;
self.read_mini_chain(&container, e.start, size)?
} else {
self.read_chain(e.start, e.size)?
};
if data.len() < size {
return Err("damaged compound file: a stream is shorter than its declared size (the file may be truncated)".into());
}
data.truncate(size);
Ok(Some(data))
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;

View File

@ -4,7 +4,7 @@
use std::fmt::Write as _;
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS};
use gridcraft_model::{Anchor, AnchorMode, Color, Image, Shape, ShapeKind, Sheet, Theme};
use gridcraft_model::{Anchor, AnchorMode, Color, Image, Shape, ShapeFormat, ShapeKind, ShapeTextAlign, ShapeTextAnchor, Sheet, Theme};
use crate::IoError;
use crate::read::Ctx;
@ -217,7 +217,44 @@ fn read_shape(obj: &El, anchor: Anchor) -> Option<Shape> {
tb.kids("p").map(|p| p.kids("r").filter_map(|r| r.child("t")).map(|t| t.text.as_str()).collect::<String>()).collect::<Vec<_>>().join("\n")
})
.unwrap_or_default();
Some(Shape { id: 0, kind, anchor, fill, line, text })
let mut format = read_text_format(obj.child("txBody"));
format.no_line = ln.is_some_and(|l| l.child("noFill").is_some());
Some(Shape { id: 0, kind, anchor, fill, line, text, format })
}
/// A shape's text formatting: vertical anchor (`a:bodyPr anchor`), the first paragraph's
/// alignment (`a:pPr algn`) and the first run's properties (`a:rPr`: size, bold, italic, fill,
/// outline, `a:latin` typeface).
fn read_text_format(body: Option<&El>) -> ShapeFormat {
let mut f = ShapeFormat::default();
let Some(body) = body else { return f };
f.anchor = body.child("bodyPr").and_then(|b| b.attr("anchor")).map(|a| match a {
"ctr" => ShapeTextAnchor::Middle,
"b" => ShapeTextAnchor::Bottom,
_ => ShapeTextAnchor::Top,
});
let first_p = body.kids("p").next();
f.align = first_p.and_then(|p| p.child("pPr")).and_then(|p| p.attr("algn")).map(|a| match a {
"ctr" => ShapeTextAlign::Center,
"r" => ShapeTextAlign::Right,
_ => ShapeTextAlign::Left,
});
let rpr = body.kids("p").flat_map(|p| p.kids("r")).find_map(|r| r.child("rPr"));
if let Some(r) = rpr {
// `sz` is in hundredths of a point (100..=400000 in the schema).
f.font_size = r.attr_f64("sz").filter(|v| v.is_finite()).map(|v| (v / 100.0).clamp(1.0, 4000.0) as f32);
f.bold = r.flag("b", false);
f.italic = r.flag("i", false);
f.font = r.child("latin").and_then(|l| l.attr("typeface")).filter(|t| !t.is_empty() && !t.starts_with('+')).map(str::to_string);
f.text_color = r.child("solidFill").and_then(dml_color);
f.text_outline = r.child("ln").and_then(|l| {
let c = l.child("solidFill").and_then(dml_color)?;
// Width in EMU (12700 per point); Excel's default hairline is 0.75 pt.
let w = l.attr_f64("w").filter(|w| w.is_finite()).map_or(0.75, |w| (w / 12700.0).clamp(0.0, 100.0)) as f32;
Some((c, w))
});
}
f
}
// ---------------------------------------------------------------- writing
@ -341,7 +378,9 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String {
let _ = write!(s, "<a:solidFill>{c}</a:solidFill>");
}
let line_c = srgb(&sh.line, theme);
if line_c.is_some() || sh.kind == ShapeKind::Arrow {
if sh.format.no_line {
s.push_str("<a:ln><a:noFill/></a:ln>");
} else if line_c.is_some() || sh.kind == ShapeKind::Arrow {
s.push_str("<a:ln w=\"9525\">");
match line_c {
Some(c) => {
@ -356,12 +395,30 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String {
}
s.push_str("</xdr:spPr>");
if !sh.text.is_empty() {
s.push_str("<xdr:txBody><a:bodyPr wrap=\"square\" rtlCol=\"0\" anchor=\"t\"/><a:lstStyle/>");
let f = &sh.format;
let anchor = match f.anchor {
Some(ShapeTextAnchor::Top) => " anchor=\"t\"",
Some(ShapeTextAnchor::Middle) => " anchor=\"ctr\"",
Some(ShapeTextAnchor::Bottom) => " anchor=\"b\"",
None if sh.kind == ShapeKind::TextBox => " anchor=\"t\"",
None => "",
};
let _ = write!(s, "<xdr:txBody><a:bodyPr wrap=\"square\" rtlCol=\"0\"{anchor}/><a:lstStyle/>");
let ppr = match f.align {
None => "",
Some(ShapeTextAlign::Left) => "<a:pPr algn=\"l\"/>",
Some(ShapeTextAlign::Center) => "<a:pPr algn=\"ctr\"/>",
Some(ShapeTextAlign::Right) => "<a:pPr algn=\"r\"/>",
};
let (rpr_attrs, rpr_body) = run_props(f, theme);
for line in sh.text.split('\n') {
if line.is_empty() {
s.push_str("<a:p><a:endParaRPr lang=\"en-US\"/></a:p>");
let _ = write!(s, "<a:p>{ppr}<a:endParaRPr lang=\"en-US\"{rpr_attrs}/></a:p>");
} else if rpr_body.is_empty() {
let _ = write!(s, "<a:p>{ppr}<a:r><a:rPr lang=\"en-US\"{rpr_attrs}/><a:t>{}</a:t></a:r></a:p>", esc(line));
} else {
let _ = write!(s, "<a:p><a:r><a:rPr lang=\"en-US\"/><a:t>{}</a:t></a:r></a:p>", esc(line));
let _ =
write!(s, "<a:p>{ppr}<a:r><a:rPr lang=\"en-US\"{rpr_attrs}>{rpr_body}</a:rPr><a:t>{}</a:t></a:r></a:p>", esc(line));
}
}
s.push_str("</xdr:txBody>");
@ -376,6 +433,35 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String {
s
}
/// A text run's `a:rPr` attributes (size, bold, italic) and children in schema order (outline,
/// fill, typeface).
fn run_props(f: &ShapeFormat, theme: &Theme) -> (String, String) {
let mut attrs = String::new();
if let Some(sz) = f.font_size.filter(|v| v.is_finite()) {
let _ = write!(attrs, " sz=\"{}\"", (sz.clamp(1.0, 4000.0) * 100.0).round() as u32);
}
if f.bold {
attrs.push_str(" b=\"1\"");
}
if f.italic {
attrs.push_str(" i=\"1\"");
}
let mut body = String::new();
if let Some((c, w)) = &f.text_outline
&& let Some(c) = srgb(c, theme)
{
let emu = (w.clamp(0.0, 100.0) * 12700.0).round() as u32;
let _ = write!(body, "<a:ln w=\"{emu}\"><a:solidFill>{c}</a:solidFill></a:ln>");
}
if let Some(c) = f.text_color.as_ref().and_then(|c| srgb(c, theme)) {
let _ = write!(body, "<a:solidFill>{c}</a:solidFill>");
}
if let Some(font) = &f.font {
let _ = write!(body, "<a:latin typeface=\"{}\"/>", esc(font));
}
(attrs, body)
}
/// File extension for an image MIME type.
pub fn image_ext(mime: &str, data: &[u8]) -> &'static str {
match mime.to_ascii_lowercase().as_str() {

View File

@ -132,6 +132,8 @@ mod tests {
assert_eq!(to_file(&Formula::new("FILTER(A1:A3,B1:B3)")), "_xlfn._xlws.FILTER(A1:A3,B1:B3)");
assert_eq!(from_file("_xlfn.XLOOKUP(1,A:A,B:B)"), "XLOOKUP(1,A:A,B:B)");
assert_eq!(text_to_file("=IFS(A1>0,1)"), "_xlfn.IFS(A1>0,1)");
assert_eq!(to_file(&Formula::new("REGEXTEST(A1,\"x\")&ENCODEURL(B1)")), "_xlfn.REGEXTEST(A1,\"x\")&_xlfn.ENCODEURL(B1)");
assert_eq!(to_file(&Formula::new("GROUPBY(A1:A3,B1:B3,\"SUM\")")), "_xlfn.GROUPBY(A1:A3,B1:B3,\"SUM\")");
}
#[test]

View File

@ -1,4 +1,5 @@
//! GridCraft file formats: XLSX (transitional and strict) and CSV import/export, plus XLSB and ODS data import.
//! GridCraft file formats: XLSX (transitional and strict) and CSV import/export, plus XLSB and ODS data import
//! and legacy Excel 97-2003 `.xls` (BIFF8) import.
//!
//! Clean-room implementation from ECMA-376 / ISO/IEC 29500 and observed behaviour. All entry
//! points work on byte slices (no file system access), so the crate builds for WebAssembly.
@ -20,6 +21,7 @@ mod feature_bags;
mod fmla;
mod ods;
mod package;
mod passthrough;
mod pivot;
mod read;
mod richdata;
@ -29,6 +31,7 @@ mod styles;
mod tables;
mod theme;
mod write;
mod xls;
mod xlsb;
mod xml;
@ -40,6 +43,7 @@ use gridcraft_model::Workbook;
pub use csv::{read_csv, write_csv};
pub use ods::read_ods;
pub use xls::read_xls;
pub use xlsb::read_xlsb;
/// Errors from reading or writing files.
@ -106,7 +110,9 @@ pub enum Format {
Csv,
/// A password-protected (encrypted) workbook: a compound file with an `EncryptedPackage`.
Encrypted,
/// Another compound (CFB/OLE2) file: an Excel 97-2003 `.xls` or other legacy Office binary.
/// An Excel 97-2003 `.xls` workbook: a compound file with a `Workbook` (or BIFF5 `Book`) stream.
Xls,
/// Another compound (CFB/OLE2) file: a legacy Office binary that isn't an Excel workbook.
LegacyBinary,
Unknown,
}
@ -123,9 +129,16 @@ pub fn sniff(bytes: &[u8]) -> Format {
};
}
// CFB/OLE2 compound file: a password-protected workbook when it holds an encryption
// stream, otherwise a legacy binary (.xls, .doc…).
// stream, an Excel 97-2003 workbook when it holds a workbook stream, otherwise another
// legacy binary (.doc…).
if bytes.starts_with(&cfb::SIGNATURE) {
return if cfb::is_encrypted(bytes) { Format::Encrypted } else { Format::LegacyBinary };
return if cfb::is_encrypted(bytes) {
Format::Encrypted
} else if xls::is_xls(bytes) {
Format::Xls
} else {
Format::LegacyBinary
};
}
if bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]) || bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
return Format::Csv;

View File

@ -0,0 +1,924 @@
//! Pass-through of package content GridCraft does not model, so that open → edit → save keeps it.
//!
//! On read, the raw bytes of unmodelled parts are kept with their content type and their
//! relationship graph, per owner: the package root (custom document properties, ribbon
//! customisation, add-in task panes), the workbook (custom XML, links to external workbooks with
//! their `<externalReferences>`, data connections) and each sheet (query tables, form controls,
//! ActiveX controls, OLE objects, the sheet background and printer settings, with the sheet's
//! `<picture>`, `<oleObjects>` and `<controls>` XML, the `r:id` of `<pageSetup>` and the legacy
//! VML shapes those controls need).
//!
//! On write, kept parts get fresh part names where needed and new relationship ids; the `r:id`s in
//! kept XML are rewritten to match. Kept content is conservative:
//! - Anything whose relationship graph reaches a part GridCraft reads and rewrites (worksheets,
//! drawings, charts, tables, PivotTables, comments, slicers…) is not kept: it would point at
//! content that no longer exists in the same form.
//! - External relationship targets (the path of a linked workbook) are kept as text and never
//! opened, followed or refreshed.
//! - Sheet content lives on the sheet: deleting the sheet drops it, Copy Sheet doesn't copy it.
//! Cell references inside kept parts (a control's linked cell, a query table's range) are kept
//! as they were, not adjusted to inserted or deleted rows.
//! - Strict Open XML files are not passed through (their namespaces differ from what we write).
//! - Sizes are bounded: per part and per workbook.
use std::borrow::Cow;
use std::collections::{HashMap, HashSet, VecDeque};
use std::fmt::Write as _;
use std::sync::Arc;
use gridcraft_model::{Preserved, PreservedPart, PreservedRel, PreservedXml, Sheet, Workbook};
use quick_xml::events::attributes::Attribute;
use quick_xml::events::{BytesStart, Event};
use quick_xml::name::QName;
use quick_xml::{Reader, Writer};
use crate::IoError;
use crate::package::{self, normalize};
use crate::read::Ctx;
use crate::write::{NS_REL, Out, Rels, XML_DECL};
use crate::xml::{self, esc_attr};
/// Most bytes kept for one workbook.
pub const MAX_KEPT_TOTAL: usize = 256 * 1024 * 1024;
/// Largest single kept part.
pub const MAX_KEPT_PART: u64 = 64 * 1024 * 1024;
/// Most parts kept for one workbook.
const MAX_KEPT_PARTS: usize = 4096;
/// Most relationships read from one rels part.
const MAX_RELS: usize = 10_000;
/// Largest relationships, content-types or VML part read here.
const MAX_META: u64 = 16 * 1024 * 1024;
/// Longest chain of relationships followed from an owner.
const MAX_CHAIN: usize = 16;
/// Largest kept XML fragment of a sheet.
const MAX_FRAGMENT: usize = 4 * 1024 * 1024;
/// Deepest element nesting accepted in a kept fragment.
const MAX_FRAGMENT_DEPTH: usize = 256;
#[cfg(test)]
thread_local! {
/// A smaller kept-bytes allowance for tests on this thread.
pub(crate) static KEPT_TOTAL_FOR_TEST: std::cell::Cell<usize> = const { std::cell::Cell::new(MAX_KEPT_TOTAL) };
}
fn kept_total() -> usize {
#[cfg(test)]
{
KEPT_TOTAL_FOR_TEST.with(std::cell::Cell::get)
}
#[cfg(not(test))]
{
MAX_KEPT_TOTAL
}
}
const NS_REL_STRICT: &str = "http://purl.oclc.org/ooxml/officeDocument/relationships";
const CT_VML: &str = "application/vnd.openxmlformats-officedocument.vmlDrawing";
/// Relationship kinds (last segment of the type) kept from the package root.
const PACKAGE_ROOTS: &[&str] = &["custom-properties", "extensibility", "webextensiontaskpanes"];
/// Relationship kinds kept from the workbook without a workbook element.
const WORKBOOK_ROOTS: &[&str] = &["customXml", "connections"];
/// Relationship kinds kept from a sheet without a sheet element.
const SHEET_ROOTS: &[&str] = &["queryTable"];
/// Sheet relationship kinds that mean the sheet XML may hold elements worth keeping.
const SHEET_ELEMENT_KINDS: &[&str] = &["oleObject", "ctrlProp", "control", "image", "package", "printerSettings"];
/// Kept sheet elements, in schema order (all after `<legacyDrawing>`, before `<tableParts>`).
const SHEET_SLOTS: &[&str] = &["picture", "oleObjects", "controls"];
/// Relationship kinds GridCraft reads and rewrites, or that depend on content it rewrites:
/// kept content whose graph reaches one of them is dropped.
const REWRITTEN: &[&str] = &[
"officedocument",
"core-properties",
"extended-properties",
"worksheet",
"chartsheet",
"dialogsheet",
"xlmacrosheet",
"drawing",
"chart",
"chartex",
"chartstyle",
"chartcolorstyle",
"chartusershapes",
"table",
"pivottable",
"pivotcachedefinition",
"pivotcacherecords",
"comments",
"vmldrawing",
"threadedcomment",
"person",
"sharedstrings",
"styles",
"theme",
"sheetmetadata",
"calcchain",
"vbaproject",
"rdrichvalue",
"rdrichvaluestructure",
"rdrichvaluetypes",
"rdrichvaluewebimage",
"rdarray",
"rdsupportingpropertybag",
"rdsupportingpropertybagstructure",
"richvaluerel",
"slicer",
"slicercache",
"timeline",
"timelinecache",
"powerpivotdata",
];
/// Part-name prefixes (lower case) GridCraft's writer never produces: kept parts there keep their
/// original names. Anything else is renamed so it can't collide with written parts.
const OWN_DIRS: &[&str] = &[
"customxml/",
"docprops/custom.xml",
"customui/",
"xl/externallinks/",
"xl/connections.xml",
"xl/querytables/",
"xl/ctrlprops/",
"xl/embeddings/",
"xl/activex/",
"xl/webextensions/",
"xl/printersettings/",
];
fn kind(rel_type: &str) -> &str {
rel_type.rsplit('/').next().unwrap_or("")
}
fn is_kind(r: &PreservedRel, kinds: &[&str]) -> bool {
let k = kind(&r.rel_type);
kinds.iter().any(|x| x.eq_ignore_ascii_case(k))
}
fn rewritten(r: &PreservedRel) -> bool {
!r.external && REWRITTEN.contains(&kind(&r.rel_type).to_ascii_lowercase().as_str())
}
fn rels_part_name(part: &str) -> String {
let (dir, file) = package::split_dir(part);
if dir.is_empty() { format!("_rels/{file}.rels") } else { format!("{dir}/_rels/{file}.rels") }
}
/// A part's relationships with full type URIs. `Ok(vec![])` when it has none; an error when the
/// rels part is damaged or too large (its part then can't be kept faithfully).
fn raw_rels(cx: &mut Ctx<'_>, part: &str) -> Result<Vec<PreservedRel>, String> {
let name = rels_part_name(part);
let bytes = match cx.pkg.read_limited(&name, MAX_META) {
Ok(Some(b)) => b,
Ok(None) => return Ok(vec![]),
Err(e) => return Err(e.to_string()),
};
let root = xml::parse(&bytes).map_err(|e| format!("{name}: {e}"))?;
let (dir, _) = package::split_dir(part);
let mut out = vec![];
for r in root.kids("Relationship") {
if out.len() >= MAX_RELS {
return Err(format!("{name}: too many relationships"));
}
let (Some(id), Some(target)) = (r.attr("Id"), r.attr("Target")) else { continue };
let external = r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External"));
out.push(PreservedRel {
id: id.to_string(),
rel_type: r.attr("Type").unwrap_or("").to_string(),
target: if external { target.to_string() } else { package::resolve(dir, target) },
external,
});
}
Ok(out)
}
// ---------------------------------------------------------------- reading
/// Collects kept content while a package is read.
pub struct KeptReader {
on: bool,
strict: bool,
defaults: HashMap<String, String>,
overrides: HashMap<String, String>,
budget: usize,
parts_left: usize,
}
impl KeptReader {
pub fn new(cx: &mut Ctx<'_>) -> KeptReader {
let mut k = KeptReader {
on: true,
strict: false,
defaults: HashMap::new(),
overrides: HashMap::new(),
budget: kept_total(),
parts_left: MAX_KEPT_PARTS,
};
if let Ok(root) = raw_rels(cx, "")
&& root.iter().any(|r| r.rel_type.starts_with("http://purl.oclc.org/"))
{
k.on = false;
k.strict = true;
}
match cx.pkg.read_limited("[Content_Types].xml", MAX_META).ok().flatten().and_then(|b| xml::parse(&b).ok()) {
Some(types) => {
for d in types.kids("Default") {
if let (Some(e), Some(t)) = (d.attr("Extension"), d.attr("ContentType")) {
k.defaults.insert(e.to_ascii_lowercase(), t.to_string());
}
}
for o in types.kids("Override") {
if let (Some(p), Some(t)) = (o.attr("PartName"), o.attr("ContentType")) {
k.overrides.insert(normalize(p), t.to_string());
}
}
}
None => k.on = false,
}
k
}
fn content_type(&self, part: &str) -> Option<String> {
let key = normalize(part);
if let Some(t) = self.overrides.get(&key) {
return Some(t.clone());
}
let (_, file) = package::split_dir(&key);
let ext = file.rsplit_once('.').map(|(_, e)| e)?;
self.defaults.get(ext).cloned()
}
/// The parts reachable from `root` that aren't in `seen` yet. Fails (and keeps nothing) when the
/// graph reaches rewritten content, a missing or damaged part, or a size limit.
fn closure(&mut self, cx: &mut Ctx<'_>, root: &PreservedRel, seen: &HashSet<String>) -> Result<Vec<PreservedPart>, String> {
if root.external {
return Ok(vec![]);
}
if rewritten(root) {
return Err(format!("it refers to {}", kind(&root.rel_type)));
}
let mut parts = vec![];
let mut bytes = 0usize;
let mut local: HashSet<String> = HashSet::new();
let mut queue = VecDeque::from([(root.target.clone(), 0usize)]);
while let Some((name, depth)) = queue.pop_front() {
let key = normalize(&name);
if seen.contains(&key) || !local.insert(key) {
continue;
}
if depth > MAX_CHAIN {
return Err("its relationships are nested too deeply".into());
}
if parts.len() >= self.parts_left {
return Err("it has too many parts".into());
}
let content_type = self.content_type(&name).ok_or_else(|| format!("{name} has no content type"))?;
let data = match cx.pkg.read_limited(&name, MAX_KEPT_PART) {
Ok(Some(d)) => d,
Ok(None) => return Err(format!("{name} is missing")),
Err(e) => return Err(e.to_string()),
};
bytes = bytes.saturating_add(data.len());
if bytes > self.budget {
return Err("it is too large".into());
}
let rels = raw_rels(cx, &name)?;
for r in rels.iter().filter(|r| !r.external) {
if rewritten(r) {
return Err(format!("it refers to {}", kind(&r.rel_type)));
}
queue.push_back((r.target.clone(), depth + 1));
}
parts.push(PreservedPart { name, content_type, data: Arc::from(data), rels });
}
self.budget = self.budget.saturating_sub(bytes);
self.parts_left = self.parts_left.saturating_sub(parts.len());
Ok(parts)
}
/// Keeps a set of relationships (all or nothing) and the parts behind them.
fn keep(&mut self, cx: &mut Ctx<'_>, p: &mut Preserved, roots: &[PreservedRel], seen: &mut HashSet<String>) -> Result<(), String> {
let mut new_parts: Vec<PreservedPart> = vec![];
let mut scratch = seen.clone();
for r in roots {
let parts = self.closure(cx, r, &scratch)?;
scratch.extend(parts.iter().map(|q| normalize(&q.name)));
new_parts.extend(parts);
}
*seen = scratch;
p.parts.extend(new_parts);
for r in roots {
if !p.rels.iter().any(|x| x.id == r.id) {
p.rels.push(r.clone());
}
}
Ok(())
}
/// Package-root and workbook-level content.
pub fn workbook(&mut self, cx: &mut Ctx<'_>, wb_part: &str, wb_xml: &xml::El) -> Option<Arc<Preserved>> {
if !self.on {
if self.strict {
cx.warn("unmodelled content of a Strict Open XML workbook (custom XML, links, controls…) is not kept on save");
}
return None;
}
let mut p = Preserved::default();
let mut seen = HashSet::new();
// Package root: custom document properties, ribbon customisation, task panes.
if let Ok(root) = raw_rels(cx, "") {
for r in root.iter().filter(|r| is_kind(r, PACKAGE_ROOTS)) {
let mut q = Preserved::default();
match self.keep(cx, &mut q, std::slice::from_ref(r), &mut seen) {
Ok(()) => {
p.parts.extend(q.parts);
p.package_rels.extend(q.rels);
}
Err(why) => cx.warn(format!("{} was dropped: {why}", kind(&r.rel_type))),
}
}
}
let wb_rels = raw_rels(cx, wb_part).unwrap_or_default();
for r in wb_rels.iter().filter(|r| is_kind(r, WORKBOOK_ROOTS)) {
if let Err(why) = self.keep(cx, &mut p, std::slice::from_ref(r), &mut seen) {
cx.warn(format!("{} part {} was dropped: {why}", kind(&r.rel_type), r.target));
}
}
// Links to external workbooks: `[1]` in formulas is the position in <externalReferences>,
// so they are kept all together or not at all.
let ids: Vec<&str> =
wb_xml.child("externalReferences").map(|e| e.kids("externalReference").filter_map(|x| x.attr("id")).collect()).unwrap_or_default();
if !ids.is_empty() {
let roots: Option<Vec<PreservedRel>> =
ids.iter().map(|id| wb_rels.iter().find(|r| r.id == *id && is_kind(r, &["externalLink"])).cloned()).collect();
let kept = match roots {
Some(roots) => self.keep(cx, &mut p, &roots, &mut seen),
None => Err("a link has no part".into()),
};
match kept {
Ok(()) => {
let mut x = format!("<externalReferences xmlns:r=\"{NS_REL}\">");
for id in &ids {
let _ = write!(x, "<externalReference r:id=\"{}\"/>", esc_attr(id));
}
x.push_str("</externalReferences>");
p.elements.push(PreservedXml { slot: "externalReferences".into(), xml: x });
cx.warn("links to external workbooks were kept; they are not followed or refreshed");
}
Err(why) => cx.warn(format!("links to external workbooks were dropped: {why}")),
}
}
(!p.is_empty()).then(|| Arc::new(p))
}
/// Sheet-level content of the sheet part `part`.
pub fn sheet(&mut self, cx: &mut Ctx<'_>, part: &str, name: &str) -> Option<Arc<Preserved>> {
if !self.on {
return None;
}
let rels = match raw_rels(cx, part) {
Ok(r) => r,
Err(e) => {
cx.warn(format!("sheet \"{name}\": relationships could not be read ({e}); unmodelled content was dropped"));
return None;
}
};
let mut p = Preserved::default();
let mut seen = HashSet::new();
for r in rels.iter().filter(|r| is_kind(r, SHEET_ROOTS)) {
if let Err(why) = self.keep(cx, &mut p, std::slice::from_ref(r), &mut seen) {
cx.warn(format!("sheet \"{name}\": {} was dropped: {why}", kind(&r.rel_type)));
}
}
if rels.iter().any(|r| is_kind(r, SHEET_ELEMENT_KINDS)) {
self.sheet_elements(cx, part, name, &rels, &mut p, &mut seen);
}
(!p.is_empty()).then(|| Arc::new(p))
}
fn sheet_elements(&mut self, cx: &mut Ctx<'_>, part: &str, name: &str, rels: &[PreservedRel], p: &mut Preserved, seen: &mut HashSet<String>) {
let scan = match cx.pkg.read(part) {
Ok(Some(bytes)) => scan_sheet(&bytes),
_ => None,
};
let Some(scan) = scan else {
cx.warn(format!("sheet \"{name}\": controls and embedded objects could not be read and were dropped"));
return;
};
// Printer settings (a binary part per sheet) hang off <pageSetup r:id>.
if let Some(id) = scan.page_setup.as_deref()
&& let Some(r) = rels.iter().find(|r| r.id == id && is_kind(r, &["printerSettings"]))
{
match self.keep(cx, p, std::slice::from_ref(r), seen) {
Ok(()) => p.elements.push(PreservedXml { slot: "pageSetup".into(), xml: r.id.clone() }),
Err(why) => cx.warn(format!("sheet \"{name}\": printer settings were dropped: {why}")),
}
}
let mut frags = scan.fragments;
// Controls and OLE objects are drawn from shapes in the legacy VML drawing.
if frags.iter().any(|(slot, _)| slot != "picture") {
match self.vml(cx, rels, scan.legacy_drawing.as_deref(), seen) {
Ok((vml, parts)) => {
p.parts.extend(parts);
p.vml = Some(vml);
}
Err(why) => {
cx.warn(format!("sheet \"{name}\": controls and embedded objects were dropped: {why}"));
frags.retain(|(slot, _)| slot == "picture");
}
}
}
for (slot, x) in frags {
let mut ids = vec![];
let ok = map_rel_ids(&x, &mut |id| {
ids.push(id.to_string());
Some(id.to_string())
})
.is_some();
let roots: Option<Vec<PreservedRel>> = ids.iter().map(|id| rels.iter().find(|r| r.id == *id).cloned()).collect();
let kept = match (ok, roots) {
(true, Some(roots)) => self.keep(cx, p, &roots, seen),
(false, _) => Err("its XML is damaged".into()),
(_, None) => Err("it refers to a missing relationship".into()),
};
match kept {
Ok(()) => p.elements.push(PreservedXml { slot, xml: x }),
Err(why) => cx.warn(format!("sheet \"{name}\": {slot} were dropped: {why}")),
}
}
if p.elements.iter().any(|e| e.slot == "oleObjects" || e.slot == "controls") {
cx.warn(format!("sheet \"{name}\": embedded objects and form controls are not shown; they are kept for saving"));
} else {
p.vml = None;
}
}
/// The sheet's VML drawing without its notes (GridCraft writes those), and the parts its
/// shapes refer to (preview images).
fn vml(
&mut self,
cx: &mut Ctx<'_>,
rels: &[PreservedRel],
rid: Option<&str>,
seen: &mut HashSet<String>,
) -> Result<(PreservedPart, Vec<PreservedPart>), String> {
let rel = rid.and_then(|id| rels.iter().find(|r| r.id == id && !r.external)).ok_or("the sheet has no VML drawing")?;
// Charged to the kept total like any other part: many sheets can point at one highly
// compressed VML drawing, and each keeps its own copy.
if self.budget == 0 {
return Err("it is too large".into());
}
let limit = MAX_META.min(u64::try_from(self.budget).unwrap_or(u64::MAX));
let data = match cx.pkg.read_limited(&rel.target, limit) {
Ok(Some(d)) => d,
Ok(None) => return Err(format!("{} is missing", rel.target)),
Err(IoError::TooLarge(_)) => return Err("it is too large".into()),
Err(e) => return Err(e.to_string()),
};
self.budget = self.budget.saturating_sub(data.len());
let text = String::from_utf8(data).map_err(|_| "the VML drawing is not UTF-8".to_string())?;
let text = strip_notes(&text);
let vrels = raw_rels(cx, &rel.target)?;
let mut q = Preserved::default();
let roots: Vec<PreservedRel> = vrels.iter().filter(|r| !r.external).cloned().collect();
self.keep(cx, &mut q, &roots, seen)?;
let vml = PreservedPart { name: rel.target.clone(), content_type: CT_VML.into(), data: Arc::from(text.into_bytes()), rels: vrels };
Ok((vml, q.parts))
}
}
/// What a sheet part holds for pass-through.
struct SheetScan {
/// (slot, XML with the namespace declarations it needs).
fragments: Vec<(String, String)>,
legacy_drawing: Option<String>,
/// The relationship id on `<pageSetup>` (printer settings).
page_setup: Option<String>,
}
/// Finds the sheet's `<picture>`, `<oleObjects>`, `<controls>` (also inside
/// `<mc:AlternateContent>`) and the `<legacyDrawing>` id. `None` when the XML is damaged.
fn scan_sheet(bytes: &[u8]) -> Option<SheetScan> {
let data = xml::to_utf8(bytes);
let text = std::str::from_utf8(&data).ok()?;
let mut reader = Reader::from_str(text);
let mut decls: Vec<(String, String)> = vec![];
let mut out = SheetScan { fragments: vec![], legacy_drawing: None, page_setup: None };
let mut in_root = false;
loop {
let start = usize::try_from(reader.buffer_position()).ok()?;
match reader.read_event().ok()? {
Event::Eof => break,
Event::Start(e) if !in_root => {
in_root = true;
for a in e.attributes().with_checks(false).flatten() {
if let Some(p) = a.key.as_ref().strip_prefix(b"xmlns:")
&& let (Ok(p), Ok(v)) = (std::str::from_utf8(p), std::str::from_utf8(&a.value))
&& !v.contains('"')
{
decls.push((p.to_string(), v.to_string()));
}
}
}
Event::Start(e) => {
let local = e.local_name().as_ref().to_vec();
match local.as_slice() {
b"pageSetup" => out.page_setup = rel_id_attr(&e),
b"legacyDrawing" => out.legacy_drawing = rel_id_attr(&e),
_ => {}
}
reader.read_to_end(e.name()).ok()?;
let end = usize::try_from(reader.buffer_position()).ok()?;
let wanted = matches!(local.as_slice(), b"picture" | b"oleObjects" | b"controls" | b"AlternateContent");
if wanted && end.saturating_sub(start) <= MAX_FRAGMENT {
let frag = text.get(start..end)?;
push_fragment(&mut out, frag, &local, &decls);
}
}
Event::Empty(e) if in_root => {
let local = e.local_name().as_ref().to_vec();
if local == b"legacyDrawing" || local == b"pageSetup" {
let id = rel_id_attr(&e);
if local == b"pageSetup" {
out.page_setup = id;
} else {
out.legacy_drawing = id;
}
} else if local == b"picture"
&& let Some(frag) = text.get(start..usize::try_from(reader.buffer_position()).ok()?)
{
push_fragment(&mut out, frag, &local, &decls);
}
}
Event::End(_) => break,
_ => {}
}
}
Some(out)
}
/// The `r:id` of an element (an `id` attribute with a namespace prefix).
fn rel_id_attr(e: &BytesStart<'_>) -> Option<String> {
e.attributes()
.with_checks(false)
.flatten()
.find(|a| a.key.local_name().as_ref() == b"id" && a.key.prefix().is_some())
.and_then(|a| String::from_utf8(a.value.to_vec()).ok())
}
fn push_fragment(out: &mut SheetScan, frag: &str, local: &[u8], decls: &[(String, String)]) {
let frag = with_decls(frag, decls);
let slot = if local == b"AlternateContent" {
// The kept element is the one the first choice holds.
let Ok(el) = xml::parse(frag.as_bytes()) else { return };
match el.child("Choice").and_then(|c| c.children.first()) {
Some(c) => c.name.clone(),
None => return,
}
} else {
String::from_utf8_lossy(local).into_owned()
};
if SHEET_SLOTS.contains(&slot.as_str()) && !out.fragments.iter().any(|(s, _)| *s == slot) {
out.fragments.push((slot, frag));
}
}
/// Adds the root's namespace declarations a fragment doesn't declare itself.
fn with_decls(frag: &str, decls: &[(String, String)]) -> String {
let Some(name_end) = frag.char_indices().skip(1).find(|(_, c)| c.is_whitespace() || *c == '/' || *c == '>').map(|(i, _)| i) else {
return frag.to_string();
};
let head = frag.get(..frag.find('>').unwrap_or(frag.len())).unwrap_or("");
let mut add = String::new();
for (p, v) in decls {
if !head.contains(&format!("xmlns:{p}=")) {
let _ = write!(add, " xmlns:{p}=\"{v}\"");
}
}
format!("{}{add}{}", frag.get(..name_end).unwrap_or(""), frag.get(name_end..).unwrap_or(""))
}
/// Byte ranges of the `<v:shape …>…</v:shape>` (or self-closed) elements in VML text.
fn shape_spans(vml: &str) -> Vec<(usize, usize)> {
let mut spans = vec![];
let mut from = 0;
while let Some(i) = vml.get(from..).and_then(|s| s.find("<v:shape")).map(|i| i + from) {
let after = i + "<v:shape".len();
let is_shape = vml.get(after..).and_then(|s| s.chars().next()).is_some_and(|c| c.is_whitespace() || c == '>' || c == '/');
if !is_shape {
from = after;
continue;
}
let Some(tag_end) = vml.get(i..).and_then(|s| s.find('>')).map(|j| i + j + 1) else { break };
let end = if vml.get(..tag_end).is_some_and(|t| t.ends_with("/>")) {
tag_end
} else {
match vml.get(tag_end..).and_then(|s| s.find("</v:shape>")) {
Some(j) => tag_end + j + "</v:shape>".len(),
None => break,
}
};
spans.push((i, end));
from = end;
}
spans
}
/// VML without its note shapes (`ObjectType="Note"`).
fn strip_notes(vml: &str) -> String {
let mut out = String::with_capacity(vml.len());
let mut last = 0;
for (a, b) in shape_spans(vml) {
let shape = vml.get(a..b).unwrap_or("");
if shape.contains("ObjectType=\"Note\"") {
out.push_str(vml.get(last..a).unwrap_or(""));
last = b;
}
}
out.push_str(vml.get(last..).unwrap_or(""));
out
}
/// Shape numbers (`_x0000_s1025` → 1025) used in VML text.
fn shape_numbers(vml: &str) -> Vec<u64> {
vml.match_indices("_x0000_s")
.filter_map(|(i, m)| {
let digits: String = vml.get(i + m.len()..)?.chars().take_while(char::is_ascii_digit).take(18).collect();
digits.parse().ok()
})
.collect()
}
/// Kept VML (controls, OLE objects) plus GridCraft's note shapes: the notes get ids in a fresh
/// shape block so they can't collide with the kept shapes the sheet XML refers to.
fn merge_vml(kept: &str, ours: &str) -> String {
let Some(close) = kept.rfind("</xml>") else { return ours.to_string() };
let block = shape_numbers(kept).into_iter().max().unwrap_or(0) / 1024 + 1;
let mut add = String::new();
if !kept.contains("id=\"_x0000_t202\"")
&& let Some(i) = ours.find("<v:shapetype")
&& let Some(j) = ours.get(i..).and_then(|s| s.find("</v:shapetype>"))
{
add.push_str(ours.get(i..i + j + "</v:shapetype>".len()).unwrap_or(""));
}
for (k, (a, b)) in shape_spans(ours).into_iter().enumerate() {
let shape = ours.get(a..b).unwrap_or("");
let id = block.saturating_mul(1024).saturating_add(1 + k as u64);
match (shape.find("id=\"_x0000_s"), shape.find("id=\"_x0000_s").and_then(|i| shape.get(i + 4..)?.find('"').map(|j| i + 4 + j))) {
(Some(i), Some(j)) => {
let _ = write!(add, "{}id=\"_x0000_s{id}\"{}", shape.get(..i).unwrap_or(""), shape.get(j + 1..).unwrap_or(""));
}
_ => add.push_str(shape),
}
}
let mut head = kept.get(..close).unwrap_or("").to_string();
// Claim the block in the drawing's id map.
if let Some(i) = head.find("<o:idmap")
&& let Some(d) = head.get(i..).and_then(|s| s.find("data=\"")).map(|d| i + d + "data=\"".len())
&& let Some(e) = head.get(d..).and_then(|s| s.find('"')).map(|e| d + e)
{
head.insert_str(e, &format!(",{block}"));
}
format!("{head}{add}{}", kept.get(close..).unwrap_or(""))
}
/// Rewrites relationship-id attributes (`r:id`, `r:embed`…: any attribute in the relationships
/// namespace) of an XML fragment through `map`. `None` when the fragment is damaged or `map` has
/// no id for one of them.
pub fn map_rel_ids(x: &str, map: &mut dyn FnMut(&str) -> Option<String>) -> Option<String> {
let mut reader = Reader::from_str(x);
let mut writer = Writer::new(Vec::with_capacity(x.len()));
let mut prefixes: HashSet<Vec<u8>> = HashSet::new();
let mut depth = 0usize;
loop {
match reader.read_event().ok()? {
Event::Eof => break,
Event::Start(e) => {
depth += 1;
if depth > MAX_FRAGMENT_DEPTH {
return None;
}
let e = map_start(&e, &mut prefixes, map)?;
writer.write_event(Event::Start(e)).ok()?;
}
Event::Empty(e) => {
let e = map_start(&e, &mut prefixes, map)?;
writer.write_event(Event::Empty(e)).ok()?;
}
Event::End(e) => {
depth = depth.saturating_sub(1);
writer.write_event(Event::End(e)).ok()?;
}
ev => writer.write_event(ev).ok()?,
}
}
String::from_utf8(writer.into_inner()).ok()
}
fn map_start(e: &BytesStart<'_>, prefixes: &mut HashSet<Vec<u8>>, map: &mut dyn FnMut(&str) -> Option<String>) -> Option<BytesStart<'static>> {
for a in e.attributes().with_checks(false) {
let a = a.ok()?;
if let Some(p) = a.key.as_ref().strip_prefix(b"xmlns:")
&& (a.value.as_ref() == NS_REL.as_bytes() || a.value.as_ref() == NS_REL_STRICT.as_bytes())
{
prefixes.insert(p.to_vec());
}
}
let name = String::from_utf8(e.name().as_ref().to_vec()).ok()?;
let mut out = BytesStart::new(name);
for a in e.attributes().with_checks(false) {
let a = a.ok()?;
let key = a.key.as_ref();
let is_rel = !key.starts_with(b"xmlns") && a.key.prefix().is_some_and(|p| prefixes.contains(p.as_ref()));
if is_rel {
let old = std::str::from_utf8(&a.value).ok()?;
let new = map(old)?;
out.push_attribute(Attribute { key: QName(key), value: Cow::Owned(esc_attr(&new).into_bytes()) });
} else {
out.push_attribute(Attribute { key: QName(key), value: a.value.clone() });
}
}
Some(out)
}
// ---------------------------------------------------------------- writing
/// Names given to kept parts while a package is written.
#[derive(Default)]
pub struct KeptOut {
used: HashSet<String>,
n: u32,
/// Package-root relationships (type, target, external), added when `_rels/.rels` is written.
root: Vec<(String, String, bool)>,
}
impl KeptOut {
/// A part name for a kept part: its original name when that can't collide with anything
/// written, otherwise a fresh one in the same folder.
fn claim(&mut self, original: &str) -> String {
let lower = original.to_ascii_lowercase();
let own = OWN_DIRS.iter().any(|d| lower.starts_with(d));
if own && self.used.insert(lower) {
return original.to_string();
}
let (dir, file) = package::split_dir(original);
let (stem, ext) = match file.rsplit_once('.') {
Some((s, e)) => (s, format!(".{e}")),
None => (file, String::new()),
};
let stem = if own { format!("{stem}_") } else { "kept".to_string() };
loop {
self.n = self.n.saturating_add(1);
let file = format!("{stem}{}{ext}", self.n);
let cand = if dir.is_empty() { file } else { format!("{dir}/{file}") };
if self.used.insert(cand.to_ascii_lowercase()) || self.n == u32::MAX {
return cand;
}
}
}
}
/// Original part name (normalized) → written part name.
type NameMap = HashMap<String, String>;
fn rel_target(r: &PreservedRel, names: &NameMap) -> Option<String> {
if r.external { Some(r.target.clone()) } else { names.get(&normalize(&r.target)).map(|n| format!("/{n}")) }
}
/// A kept part's relationships, ids unchanged, targets renamed.
fn part_rels_xml(rels: &[PreservedRel], names: &NameMap) -> String {
let mut s = format!("{XML_DECL}<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">");
for r in rels {
let Some(target) = rel_target(r, names) else { continue };
let _ = write!(
s,
"<Relationship Id=\"{}\" Type=\"{}\" Target=\"{}\"{}/>",
esc_attr(&r.id),
esc_attr(&r.rel_type),
esc_attr(&target),
if r.external { " TargetMode=\"External\"" } else { "" }
);
}
s.push_str("</Relationships>");
s
}
fn write_parts(out: &mut Out, parts: &[PreservedPart]) -> NameMap {
let mut names = NameMap::new();
for p in parts {
names.entry(normalize(&p.name)).or_insert_with(|| out.kept.claim(&p.name));
}
let mut written = HashSet::new();
for p in parts {
let Some(new) = names.get(&normalize(&p.name)).cloned() else { continue };
if !written.insert(new.clone()) {
continue;
}
out.part(&new, Some(&p.content_type), p.data.to_vec());
if !p.rels.is_empty() {
out.part(&rels_part_name(&new), None, part_rels_xml(&p.rels, &names).into_bytes());
}
}
names
}
/// Adds the owner's relationships; returns old id → new id.
fn attach(rels: &mut Rels, list: &[PreservedRel], names: &NameMap) -> HashMap<String, String> {
let mut ids = HashMap::new();
for r in list {
if let Some(target) = rel_target(r, names) {
let id = rels.add_raw(&r.rel_type, &target, r.external);
ids.insert(r.id.clone(), id);
}
}
ids
}
fn remap(x: &str, ids: &HashMap<String, String>) -> Option<String> {
map_rel_ids(x, &mut |id| ids.get(id).cloned())
}
/// Writes the workbook's kept parts and relationships; returns the workbook XML elements that
/// go right after `<sheets>` (`<externalReferences>`).
pub fn write_workbook(wb: &Workbook, wb_rels: &mut Rels, out: &mut Out) -> String {
let Some(p) = wb.preserved.as_deref() else { return String::new() };
let names = write_parts(out, &p.parts);
let ids = attach(wb_rels, &p.rels, &names);
for r in &p.package_rels {
if let Some(target) = rel_target(r, &names) {
out.kept.root.push((r.rel_type.clone(), target, r.external));
}
}
p.elements.iter().filter(|e| e.slot == "externalReferences").filter_map(|e| remap(&e.xml, &ids)).collect()
}
/// Adds the kept package-root relationships (custom properties…) to `_rels/.rels`.
pub fn write_package_rels(root: &mut Rels, out: &mut Out) {
for (ty, target, external) in std::mem::take(&mut out.kept.root) {
root.add_raw(&ty, &target, external);
}
}
/// A sheet's kept content being written.
pub struct SheetKept<'a> {
p: Option<&'a Preserved>,
names: NameMap,
ids: HashMap<String, String>,
}
impl<'a> SheetKept<'a> {
/// Writes the sheet's kept parts and adds their relationships to the sheet's.
pub fn write(sheet: &'a Sheet, rels: &mut Rels, out: &mut Out) -> SheetKept<'a> {
let Some(p) = sheet.preserved.as_deref() else { return SheetKept { p: None, names: NameMap::new(), ids: HashMap::new() } };
let names = write_parts(out, &p.parts);
let ids = attach(rels, &p.rels, &names);
SheetKept { p: Some(p), names, ids }
}
fn vml(&self) -> Option<&'a PreservedPart> {
self.p?.vml.as_ref()
}
fn write_vml_rels(&self, vml: &PreservedPart, name: &str, out: &mut Out) {
if !vml.rels.is_empty() {
out.part(&rels_part_name(name), None, part_rels_xml(&vml.rels, &self.names).into_bytes());
}
}
/// GridCraft's notes VML for part `name`, merged with the kept control and object shapes.
pub fn notes_vml(&self, ours: String, name: &str, out: &mut Out) -> String {
let Some(vml) = self.vml() else { return ours };
self.write_vml_rels(vml, name, out);
merge_vml(&String::from_utf8_lossy(&vml.data), &ours)
}
/// The kept VML drawing of a sheet without notes; its new relationship id.
pub fn lone_vml(&self, rels: &mut Rels, out: &mut Out) -> Option<String> {
let vml = self.vml()?;
let name = out.kept.claim(&vml.name);
out.part(&name, Some(CT_VML), vml.data.to_vec());
self.write_vml_rels(vml, &name, out);
Some(rels.add("vmlDrawing", &format!("/{name}")))
}
/// The new relationship id for `<pageSetup r:id>` (printer settings).
pub fn page_setup_id(&self) -> Option<&str> {
let old = &self.p?.elements.iter().find(|e| e.slot == "pageSetup")?.xml;
self.ids.get(old).map(String::as_str)
}
/// The kept sheet elements, in schema order.
pub fn elements(&self) -> String {
let Some(p) = self.p else { return String::new() };
let mut s = String::new();
for slot in SHEET_SLOTS {
for e in p.elements.iter().filter(|e| e.slot == *slot) {
if let Some(x) = remap(&e.xml, &self.ids) {
s.push_str(&x);
}
}
}
s
}
}

View File

@ -98,6 +98,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
return Err(IoError::Format(format!("unexpected root element <{}> in workbook part", wb_xml.name)));
}
let wb_rels = cx.pkg.rels(&wb_part)?;
let mut kept = crate::passthrough::KeptReader::new(&mut cx);
cx.cell_pictures = match crate::richdata::read(&mut cx, &wb_rels) {
Ok(pictures) => pictures,
Err(error) => {
@ -184,6 +185,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
}
if let Some(p) = wb_xml.child("workbookProtection") {
wb.protected_structure = p.flag("lockStructure", false);
wb.structure_password = if wb.protected_structure { crate::sheet_read::read_password(p, "workbook") } else { None };
}
let active_tab = wb_xml.path(&["bookViews", "workbookView"]).and_then(|v| v.attr_u32("activeTab")).unwrap_or(0) as usize;
@ -218,6 +220,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
Err(IoError::TooLarge(m)) => return Err(IoError::TooLarge(m)),
Err(e) => cx.warn(format!("PivotTables on sheet \"{name}\" could not be read: {e}")),
}
sheet.preserved = kept.sheet(&mut cx, &rel.target, &name);
sheet.visibility = match se.attr("state") {
Some("hidden") => Visibility::Hidden,
Some("veryHidden") => Visibility::VeryHidden,
@ -311,12 +314,20 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
wb.props.company = x.child("Company").map(|e| e.text.trim().to_string()).unwrap_or_default();
}
if wb_rels.iter().any(|r| r.kind == "vbaProject") {
cx.warn("macros (VBA project) are not supported and were dropped");
}
if wb_rels.iter().any(|r| r.kind == "externalLink") {
cx.warn("links to external workbooks are not supported");
// A VBA project is carried through unchanged: GridCraft never runs macros, but preserving the
// binary keeps macro-enabled workbooks from being silently stripped on save.
if let Some(r) = find_rel(&wb_rels, "vbaProject").cloned() {
match cx.pkg.read(&r.target) {
Ok(Some(data)) => {
wb.vba_project = Some(data);
cx.warn("macros (VBA project) were kept but not executed");
}
Ok(None) => cx.warn("macros (VBA project) reference a missing part and were dropped"),
Err(IoError::TooLarge(m)) => return Err(IoError::TooLarge(m)),
Err(e) => cx.warn(format!("macros (VBA project) could not be read and were dropped: {e}")),
}
}
wb.preserved = kept.workbook(&mut cx, &wb_part, &wb_xml);
// Calls of LAMBDA names are spelled like the names (the parser upper-cases them).
wb.apply_name_call_case();
wb.styles = cx.styles;

View File

@ -137,17 +137,29 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoE
}
}
"headerFooter" => {
sheet.print.header = e.child("oddHeader").map(|h| h.text.clone()).unwrap_or_default();
sheet.print.footer = e.child("oddFooter").map(|h| h.text.clone()).unwrap_or_default();
let pr = &mut sheet.print;
for (tag, slot) in [
("oddHeader", &mut pr.header),
("oddFooter", &mut pr.footer),
("evenHeader", &mut pr.even_header),
("evenFooter", &mut pr.even_footer),
("firstHeader", &mut pr.first_header),
("firstFooter", &mut pr.first_footer),
] {
// Excel caps each at 255 characters; a hostile file can't make them huge.
*slot = e.child(tag).map(|h| h.text.chars().take(255).collect()).unwrap_or_default();
}
pr.different_first = e.flag("differentFirst", false);
pr.different_odd_even = e.flag("differentOddEven", false);
pr.hf_scale_with_doc = e.flag("scaleWithDoc", true);
pr.hf_align_with_margins = e.flag("alignWithMargins", true);
}
"rowBreaks" => sheet.print.row_breaks = e.kids("brk").filter_map(|b| b.attr_u32("id")).filter(|&r| r > 0 && r < MAX_ROWS).collect(),
"colBreaks" => sheet.print.col_breaks = e.kids("brk").filter_map(|b| b.attr_u32("id")).filter(|&c| c > 0 && c < MAX_COLS).collect(),
"sheetProtection" if e.flag("sheet", false) => {
if e.attr("password").is_some() || e.attr("hashValue").is_some() {
cx.warn(format!("sheet \"{name}\": the protection password was not imported"));
}
sheet.protection = Some(SheetProtection {
password_hash: None,
password: read_password(e, ""),
legacy_password_hash: None,
select_locked: !e.flag("selectLockedCells", false),
select_unlocked: !e.flag("selectUnlockedCells", false),
format_cells: !e.flag("formatCells", true),
@ -182,7 +194,6 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoE
}
"extLst" => read_ext(cx, e, &mut sheet),
"protectedRanges" => read_protected_ranges(e, &mut sheet),
"oleObjects" | "controls" => cx.warn(format!("sheet \"{name}\": embedded objects and form controls are not supported")),
_ => {}
}
}
@ -209,6 +220,35 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoE
Ok(sheet)
}
/// A protection password hash: the iterated attributes (`algorithmName`, `hashValue`,
/// `saltValue`, `spinCount`) or the legacy 16-bit `password`, each name preceded by `prefix`
/// (`workbook` gives `workbookHashValue`…). One that can't be read keeps a hash that never
/// matches, so what it protects stays locked rather than open.
pub(crate) fn read_password(e: &El, prefix: &str) -> Option<PasswordHash> {
let attr = |base: &str| e.attr(&password_attr(prefix, base));
if attr("algorithmName").is_some() || attr("hashValue").is_some() {
return Some(PasswordHash::Iterated {
algorithm: attr("algorithmName").unwrap_or("").to_string(),
hash: attr("hashValue").unwrap_or("").to_string(),
salt: attr("saltValue").unwrap_or("").to_string(),
spin_count: e.attr_u32(&password_attr(prefix, "spinCount")).unwrap_or(0),
});
}
attr("password").map(|h| match u16::from_str_radix(h.trim(), 16) {
Ok(h) => PasswordHash::Legacy(h),
Err(_) => PasswordHash::Iterated { algorithm: String::new(), hash: String::new(), salt: String::new(), spin_count: 0 },
})
}
/// `base` preceded by `prefix` in camel case: ("workbook", "hashValue") → `workbookHashValue`.
pub(crate) fn password_attr(prefix: &str, base: &str) -> String {
let mut chars = base.chars();
match chars.next() {
Some(c) if !prefix.is_empty() => format!("{prefix}{}{}", c.to_ascii_uppercase(), chars.as_str()),
_ => base.to_string(),
}
}
/// Allow Edit Ranges. A range whose password can't be read keeps a hash that never matches
/// (or an unsupported algorithm), so it stays locked rather than open.
fn read_protected_ranges(e: &El, sheet: &mut Sheet) {
@ -217,20 +257,7 @@ fn read_protected_ranges(e: &El, sheet: &mut Sheet) {
if ranges.is_empty() {
continue;
}
let password = if p.attr("algorithmName").is_some() || p.attr("hashValue").is_some() {
Some(PasswordHash::Iterated {
algorithm: p.attr("algorithmName").unwrap_or("").to_string(),
hash: p.attr("hashValue").unwrap_or("").to_string(),
salt: p.attr("saltValue").unwrap_or("").to_string(),
spin_count: p.attr_u32("spinCount").unwrap_or(0),
})
} else {
p.attr("password").map(|h| match u16::from_str_radix(h.trim(), 16) {
Ok(h) => PasswordHash::Legacy(h),
// Unreadable: keep it locked.
Err(_) => PasswordHash::Iterated { algorithm: String::new(), hash: String::new(), salt: String::new(), spin_count: 0 },
})
};
let password = read_password(p, "");
let title = p.attr("name").unwrap_or("").to_string();
sheet.edit_ranges.push(EditRange { title, ranges, password, security_descriptor: p.attr("securityDescriptor").map(str::to_string) });
}
@ -257,6 +284,10 @@ fn read_views(e: &El, sheet: &mut Sheet) {
let rows = p.attr_f64("ySplit").unwrap_or(0.0).clamp(0.0, (MAX_ROWS - 1) as f64) as u32;
if rows > 0 || cols > 0 {
sheet.freeze = Some((rows, cols));
// With frozen panes the scroll position is the scrolling pane's first cell.
if let Some(c) = p.attr("topLeftCell").and_then(CellRef::parse) {
sheet.view_top_left = c;
}
}
}
active_pane = p.attr("activePane");

View File

@ -182,7 +182,11 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
let _ = write!(
s,
" topLeftCell=\"{}\" activePane=\"{pane}\" state=\"frozen\"/>",
CellRef::new(rows.min(gridcraft_core::MAX_ROWS - 1), cols.min(gridcraft_core::MAX_COLS - 1)).a1()
CellRef::new(
rows.max(sheet.view_top_left.row).min(gridcraft_core::MAX_ROWS - 1),
cols.max(sheet.view_top_left.col).min(gridcraft_core::MAX_COLS - 1)
)
.a1()
);
let _ = write!(s, "<selection pane=\"{pane}\" activeCell=\"{active}\" sqref=\"{active}\"/>");
}
@ -332,6 +336,9 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
if !p.select_unlocked {
s.push_str(" selectUnlockedCells=\"1\"");
}
if let Some(h) = &p.password {
s.push_str(&password_attrs(h, ""));
}
s.push_str("/>");
}
s.push_str(&protected_ranges_xml(sheet));
@ -420,6 +427,8 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
}
s.push_str("/>");
}
// Unmodelled content kept from the source file (printer settings, controls, OLE objects…).
let kept = crate::passthrough::SheetKept::write(sheet, &mut rels, out);
let m = |i: usize| num(p.margins.get(i).copied().unwrap_or(0.75) as f64);
let _ =
write!(s, "<pageMargins left=\"{}\" right=\"{}\" top=\"{}\" bottom=\"{}\" header=\"{}\" footer=\"{}\"/>", m(0), m(1), m(2), m(3), m(4), m(5));
@ -435,14 +444,36 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
if let Some((w, h)) = p.fit_to {
let _ = write!(s, " fitToWidth=\"{w}\" fitToHeight=\"{h}\"");
}
let _ = write!(s, " orientation=\"{}\"/>", if p.orientation == Orientation::Landscape { "landscape" } else { "portrait" });
if !p.header.is_empty() || !p.footer.is_empty() {
s.push_str("<headerFooter>");
if !p.header.is_empty() {
let _ = write!(s, "<oddHeader>{}</oddHeader>", esc(&p.header));
let _ = write!(s, " orientation=\"{}\"", if p.orientation == Orientation::Landscape { "landscape" } else { "portrait" });
if let Some(id) = kept.page_setup_id() {
let _ = write!(s, " r:id=\"{id}\"");
}
s.push_str("/>");
if p.has_header_footer() || p.different_first || p.different_odd_even || !p.hf_scale_with_doc || !p.hf_align_with_margins {
s.push_str("<headerFooter");
for (attr, on, default) in [
("differentOddEven", p.different_odd_even, false),
("differentFirst", p.different_first, false),
("scaleWithDoc", p.hf_scale_with_doc, true),
("alignWithMargins", p.hf_align_with_margins, true),
] {
if on != default {
let _ = write!(s, " {attr}=\"{}\"", u8::from(on));
}
}
if !p.footer.is_empty() {
let _ = write!(s, "<oddFooter>{}</oddFooter>", esc(&p.footer));
s.push('>');
// CT_HeaderFooter's element order.
for (tag, text) in [
("oddHeader", &p.header),
("oddFooter", &p.footer),
("evenHeader", &p.even_header),
("evenFooter", &p.even_footer),
("firstHeader", &p.first_header),
("firstFooter", &p.first_footer),
] {
if !text.is_empty() {
let _ = write!(s, "<{tag}>{}</{tag}>", esc(text));
}
}
s.push_str("</headerFooter>");
}
@ -519,11 +550,16 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
rels.add_uri(REL_THREADED_COMMENT, &format!("../threadedComments/threadedComment{n}.xml"));
}
out.default_type("vml", "application/vnd.openxmlformats-officedocument.vmlDrawing");
out.part(&format!("xl/drawings/vmlDrawing{n}.vml"), None, vml_xml(sheet, n).into_bytes());
let vml_name = format!("xl/drawings/vmlDrawing{n}.vml");
let vml = kept.notes_vml(vml_xml(sheet, n), &vml_name, out);
out.part(&vml_name, None, vml.into_bytes());
rels.add("comments", &format!("../comments{n}.xml"));
let rid = rels.add("vmlDrawing", &format!("../drawings/vmlDrawing{n}.vml"));
let _ = write!(s, "<legacyDrawing r:id=\"{rid}\"/>");
} else if let Some(rid) = kept.lone_vml(&mut rels, out) {
let _ = write!(s, "<legacyDrawing r:id=\"{rid}\"/>");
}
s.push_str(&kept.elements());
// Tables.
if !tables.is_empty() {
let mut parts = String::new();
@ -1107,26 +1143,33 @@ fn threaded_xml(sheet: &Sheet, ids: &BTreeMap<CellRef, Vec<String>>, persons: &m
s
}
/// A protection password hash as attributes, each name preceded by `prefix` (see
/// [`crate::sheet_read::read_password`]).
pub(crate) fn password_attrs(h: &gridcraft_model::PasswordHash, prefix: &str) -> String {
let name = |base: &str| crate::sheet_read::password_attr(prefix, base);
match h {
gridcraft_model::PasswordHash::Legacy(h) => format!(" {}=\"{h:04X}\"", name("password")),
gridcraft_model::PasswordHash::Iterated { algorithm, hash, salt, spin_count } => format!(
" {}=\"{}\" {}=\"{}\" {}=\"{}\" {}=\"{spin_count}\"",
name("algorithmName"),
esc_attr(algorithm),
name("hashValue"),
esc_attr(hash),
name("saltValue"),
esc_attr(salt),
name("spinCount")
),
}
}
/// Allow Edit Ranges: `<protectedRanges>` (after `sheetProtection`).
fn protected_ranges_xml(sheet: &Sheet) -> String {
let mut s = String::new();
for r in sheet.edit_ranges.iter().filter(|r| !r.ranges.is_empty()) {
let sqref: Vec<String> = r.ranges.iter().map(|x| x.a1()).collect();
let _ = write!(s, "<protectedRange name=\"{}\" sqref=\"{}\"", esc_attr(&r.title), sqref.join(" "));
match &r.password {
Some(gridcraft_model::PasswordHash::Legacy(h)) => {
let _ = write!(s, " password=\"{h:04X}\"");
}
Some(gridcraft_model::PasswordHash::Iterated { algorithm, hash, salt, spin_count }) => {
let _ = write!(
s,
" algorithmName=\"{}\" hashValue=\"{}\" saltValue=\"{}\" spinCount=\"{spin_count}\"",
esc_attr(algorithm),
esc_attr(hash),
esc_attr(salt)
);
}
None => {}
if let Some(h) = &r.password {
s.push_str(&password_attrs(h, ""));
}
if let Some(sd) = &r.security_descriptor {
let _ = write!(s, " securityDescriptor=\"{}\"", esc_attr(sd));

View File

@ -133,6 +133,7 @@ const FUTURE_FUNCTIONS: &[&str] = &[
"DAYS",
"DECIMAL",
"DROP",
"ENCODEURL",
"ERF.PRECISE",
"ERFC.PRECISE",
"EXPAND",
@ -157,6 +158,7 @@ const FUTURE_FUNCTIONS: &[&str] = &[
"GAMMA.INV",
"GAMMALN.PRECISE",
"GAUSS",
"GROUPBY",
"HSTACK",
"HYPGEOM.DIST",
"IFNA",
@ -199,6 +201,7 @@ const FUTURE_FUNCTIONS: &[&str] = &[
"PERCENTRANK.INC",
"PERMUTATIONA",
"PHI",
"PIVOTBY",
"POISSON.DIST",
"QUARTILE.EXC",
"QUARTILE.INC",
@ -206,6 +209,9 @@ const FUTURE_FUNCTIONS: &[&str] = &[
"RANK.AVG",
"RANK.EQ",
"REDUCE",
"REGEXEXTRACT",
"REGEXREPLACE",
"REGEXTEST",
"RRI",
"SCAN",
"SEC",

View File

@ -48,7 +48,7 @@ fn shared_formulas_and_value_types() {
#[test]
fn workbook_settings_names_and_states() {
let wb_extra = r#"<workbookPr date1904="1"/><workbookProtection lockStructure="1"/><bookViews><workbookView activeTab="0"/></bookViews>"#;
let wb_extra = r#"<workbookPr date1904="1"/><workbookProtection workbookPassword="83AF" lockStructure="1"/><bookViews><workbookView activeTab="0"/></bookViews>"#;
// Defined names must come after <sheets>; build the workbook by hand.
let wbx = format!(
r#"<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">{wb_extra}<sheets><sheet name="A" sheetId="1" r:id="rId1"/><sheet name="B" sheetId="2" state="veryHidden" r:id="rId2"/><sheet name="Chart" sheetId="3" r:id="rId3"/><sheet name="C" sheetId="4" state="hidden" r:id="rId4"/></sheets>
@ -69,6 +69,9 @@ fn workbook_settings_names_and_states() {
assert_eq!(wb.sheets.len(), 3);
assert_eq!(wb.date_system, DateSystem::D1904);
assert!(wb.protected_structure);
// The legacy structure password ("password" hashes to 83AF) is kept and really verifies.
let pw = wb.structure_password.as_ref().unwrap();
assert!(pw.verify("password").unwrap() && !pw.verify("Password").unwrap());
assert_eq!(wb.calc.mode, CalcMode::Manual);
assert!(wb.calc.iterative);
assert_eq!(wb.calc.max_iterations, 50);
@ -220,7 +223,7 @@ fn hyperlinks_comments_tables_validation_filters() {
<printOptions gridLines="1" horizontalCentered="1"/><pageMargins left="0.5" right="0.5" top="1" bottom="1" header="0.4" footer="0.4"/>
<headerFooter><oddHeader>&amp;CTitle</oddHeader><oddFooter>Page &amp;P</oddFooter></headerFooter>
<rowBreaks count="1"><brk id="10" max="16383" man="1"/></rowBreaks>
<sheetProtection sheet="1" formatCells="0" sort="0" password="CC1A"/>
<sheetProtection sheet="1" formatCells="0" sort="0" password="CBEB"/>
<tableParts count="1"><tablePart r:id="rId2"/></tableParts>
<extLst><ext uri="{CCE6A557-97BC-4b89-ADB6-D9C93CAAB3DF}"><x14:dataValidations xmlns:x14="x" xmlns:xm="m" count="1"><x14:dataValidation type="list"><x14:formula1><xm:f>Other!$A$1:$A$3</xm:f></x14:formula1><xm:sqref>G1:G4</xm:sqref></x14:dataValidation></x14:dataValidations></ext>
<ext uri="{05C60535-1F16-4fd2-B633-F4F36F0B64E0}"><x14:sparklineGroups xmlns:x14="x" xmlns:xm="m"><x14:sparklineGroup type="column" markers="1"><x14:colorSeries rgb="FF376092"/><x14:sparklines><x14:sparkline><xm:f>Data!A1:E1</xm:f><xm:sqref>F1</xm:sqref></x14:sparkline></x14:sparklines></x14:sparklineGroup></x14:sparklineGroups></ext></extLst>"#;
@ -267,7 +270,10 @@ Check this</t></r></text></comment></commentList></comments>"#;
assert_eq!(s.print.row_breaks, vec![10]);
let p = s.protection.as_ref().unwrap();
assert!(p.format_cells && p.sort && !p.insert_rows && p.select_locked);
assert!(rep.warnings.iter().any(|w| w.contains("password")));
// The legacy sheet password is imported (CBEB is the hash of "test") and verifies.
let pw = p.password.as_ref().unwrap();
assert!(pw.verify("test").unwrap() && !pw.verify("Test").unwrap());
assert!(!rep.warnings.iter().any(|w| w.contains("password")), "{:?}", rep.warnings);
assert_eq!(s.sparklines.len(), 1);
assert_eq!(s.sparklines[0].kind, gridcraft_model::SparklineKind::Column);
}
@ -448,3 +454,44 @@ fn chartex_parts_as_excel_expects_them() {
let s = &back.sheet(0).unwrap().charts[0].series[0];
assert_eq!((s.name.as_deref(), s.categories.as_deref(), s.values.as_str()), (Some("Data!$B$1"), Some("Data!$A$2:$A$5"), "Data!$B$2:$B$5"));
}
/// #377: a WordArt-style text box keeps its run size, bold, typeface, text fill and outline, its
/// centring (horizontal and vertical) and its "no outline" shape line, through a round trip.
#[test]
fn text_box_formatting_is_read_and_written() {
let drawing = r#"<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><xdr:twoCellAnchor>
<xdr:from><xdr:col>1</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>2</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from><xdr:to><xdr:col>7</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>8</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>
<xdr:sp macro="" textlink=""><xdr:nvSpPr><xdr:cNvPr id="2" name="TextBox 1"/><xdr:cNvSpPr txBox="1"/></xdr:nvSpPr>
<xdr:spPr><a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:solidFill><a:schemeClr val="bg1"/></a:solidFill><a:ln w="9525" cmpd="sng"><a:noFill/></a:ln></xdr:spPr>
<xdr:txBody><a:bodyPr vertOverflow="clip" horzOverflow="clip" wrap="square" rtlCol="0" anchor="ctr"/><a:lstStyle/>
<a:p><a:pPr algn="ctr"/><a:r><a:rPr lang="en-US" sz="5400" b="1"><a:ln w="19050"><a:solidFill><a:schemeClr val="tx1"/></a:solidFill></a:ln><a:solidFill><a:schemeClr val="bg1"/></a:solidFill><a:latin typeface="Arial Black"/></a:rPr><a:t>DRAFT</a:t></a:r></a:p></xdr:txBody></xdr:sp><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>"#;
let srels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing1.xml"/></Relationships>"#;
let bytes = minimal(
r#"<sheetData/><drawing r:id="rId1"/>"#,
&[("xl/worksheets/_rels/sheet1.xml.rels", srels), ("xl/drawings/drawing1.xml", drawing)],
"",
"",
);
let (wb, _) = read_xlsx(&bytes).unwrap();
let sp = &wb.sheet(0).unwrap().shapes[0];
assert_eq!(sp.kind, gridcraft_model::ShapeKind::TextBox);
assert_eq!(sp.text, "DRAFT");
let f = &sp.format;
assert!(f.no_line, "a:ln/a:noFill: no border");
assert_eq!(f.font_size, Some(54.0));
assert!(f.bold && !f.italic);
assert_eq!(f.font.as_deref(), Some("Arial Black"));
assert_eq!(f.text_color, Some(Color::Theme(0, 0)));
assert_eq!(f.text_outline, Some((Color::Theme(1, 0), 1.5)));
assert_eq!(f.align, Some(gridcraft_model::ShapeTextAlign::Center));
assert_eq!(f.anchor, Some(gridcraft_model::ShapeTextAnchor::Middle));
let (back, _) = read_xlsx(&crate::write_xlsx(&wb).unwrap()).unwrap();
let g = &back.sheet(0).unwrap().shapes[0].format;
let rgb = |c: Option<Color>| c.and_then(|c| c.resolve(&wb.theme));
assert_eq!((g.no_line, g.font_size, g.bold, g.font.as_deref()), (true, Some(54.0), true, Some("Arial Black")));
assert_eq!((g.align, g.anchor), (f.align, f.anchor));
assert_eq!(rgb(g.text_color), rgb(f.text_color));
assert_eq!(rgb(g.text_outline.map(|o| o.0)), rgb(f.text_outline.map(|o| o.0)));
assert_eq!(g.text_outline.map(|o| o.1), Some(1.5));
}

View File

@ -5,10 +5,12 @@ mod csv_tests;
mod fixtures;
mod malformed;
mod ods_tests;
mod passthrough_tests;
mod pivot_tests;
mod richdata_tests;
mod roundtrip;
mod smooth_roundtrip;
mod xls_tests;
mod xlsb_tests;
use std::io::Write;

View File

@ -0,0 +1,485 @@
//! Pass-through of unmodelled package content (custom XML, external links, connections, query
//! tables, printer settings, form controls, OLE objects). Packages are built in code.
use std::collections::HashMap;
use std::io::Read;
use std::sync::Arc;
use gridcraft_model::Sheet;
use super::make_zip;
use crate::package::{normalize, resolve, split_dir};
use crate::xml;
use crate::{read_xlsx, write_xlsx};
const NS_R: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
const NS_MAIN: &str = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
const PRINTER: &[u8] = &[0, 1, 2, 3, 250, 251, 252, 253, 9, 9, 9];
const OLE: &[u8] = b"\xD0\xCF\x11\xE0 not really a compound file";
const EMF: &[u8] = b"\x01\x00\x00\x00 preview";
const CUSTOM_XML: &str = r#"<?xml version="1.0"?><root xmlns="urn:example:data"><v>42</v></root>"#;
const CUSTOM_PROPS: &str = r#"<?xml version="1.0"?><Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/custom-properties"/>"#;
fn rels(items: &[(&str, &str, &str)]) -> String {
let mut s = String::from(r#"<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#);
for (id, ty, target) in items {
let ty = if ty.starts_with("http") { ty.to_string() } else { format!("{NS_R}/{ty}") };
let ext = if target.contains("://") { r#" TargetMode="External""# } else { "" };
s.push_str(&format!(r#"<Relationship Id="{id}" Type="{ty}" Target="{target}"{ext}/>"#));
}
s.push_str("</Relationships>");
s
}
const CONTENT_TYPES: &str = r#"<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="bin" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.printerSettings"/><Default Extension="emf" ContentType="image/x-emf"/><Default Extension="vml" ContentType="application/vnd.openxmlformats-officedocument.vmlDrawing"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/worksheets/sheet2.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/embeddings/oleObject1.bin" ContentType="application/vnd.openxmlformats-officedocument.oleObject"/><Override PartName="/customXml/itemProps1.xml" ContentType="application/vnd.openxmlformats-officedocument.customXmlProperties+xml"/><Override PartName="/xl/externalLinks/externalLink1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.externalLink+xml"/><Override PartName="/xl/connections.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.connections+xml"/><Override PartName="/xl/queryTables/queryTable1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.queryTable+xml"/><Override PartName="/xl/ctrlProps/ctrlProp1.xml" ContentType="application/vnd.ms-excel.controlproperties+xml"/><Override PartName="/docProps/custom.xml" ContentType="application/vnd.openxmlformats-officedocument.custom-properties+xml"/><Override PartName="/xl/comments1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml"/></Types>"#;
const EXTERNAL_LINK: &str = r#"<?xml version="1.0"?><externalLink xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><externalBook r:id="rId1"><sheetNames><sheetName val="Sheet1"/></sheetNames><sheetDataSet><sheetData sheetId="0"><row r="1"><cell r="A1"><v>7</v></cell></row></sheetData></sheetDataSet></externalBook></externalLink>"#;
const CONNECTIONS: &str = r#"<?xml version="1.0"?><connections xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><connection id="1" name="Query" type="5" refreshedVersion="6"><dbPr connection="Provider=Example" command="SELECT 1"/></connection></connections>"#;
const QUERY_TABLE: &str = r#"<?xml version="1.0"?><queryTable xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" name="ExternalData_1" connectionId="1" autoFormatId="16" applyNumberFormats="0" applyBorderFormats="0" applyFontFormats="0" applyPatternFormats="0" applyAlignmentFormats="0" applyWidthHeightFormats="0"/>"#;
const CTRL_PROP: &str = r#"<?xml version="1.0"?><formControlPr xmlns="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" objectType="CheckBox" fmlaLink="$C$1" lockText="1"/>"#;
const COMMENTS: &str = r#"<?xml version="1.0"?><comments xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><authors><author>A</author></authors><commentList><comment ref="A1" authorId="0"><text><t>note</t></text></comment></commentList></comments>"#;
const VML: &str = r##"<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel"><o:shapelayout v:ext="edit"><o:idmap v:ext="edit" data="1"/></o:shapelayout><v:shapetype id="_x0000_t75" coordsize="21600,21600" o:spt="75"/><v:shape id="_x0000_s1025" type="#_x0000_t75" style="position:absolute"><v:imagedata o:relid="rId1" o:title=""/><x:ClientData ObjectType="Pict"><x:Anchor>1, 0, 1, 0, 3, 0, 5, 0</x:Anchor><x:CF>Pict</x:CF></x:ClientData></v:shape><v:shape id="_x0000_s1026" type="#_x0000_t201" style="position:absolute"><x:ClientData ObjectType="Checkbox"><x:Anchor>4, 0, 1, 0, 6, 0, 2, 0</x:Anchor><x:FmlaLink>$C$1</x:FmlaLink></x:ClientData></v:shape><v:shape id="_x0000_s1027" type="#_x0000_t202" style="position:absolute"><v:textbox><div>old note<br></div></v:textbox><x:ClientData ObjectType="Note"><x:Row>0</x:Row><x:Column>0</x:Column></x:ClientData></v:shape></xml>"##;
/// A workbook with every kind of kept content: package root custom properties, custom XML, an
/// external link (with formulas using it), a connection, and on sheet "Data" a query table,
/// printer settings, an OLE object (in `mc:AlternateContent`), a form control, a note and the VML
/// drawing all of them share.
fn rich_package() -> Vec<u8> {
rich_package_with(CONTENT_TYPES, false)
}
/// [`rich_package`] with other content types; `twin` makes sheet "Other" a copy of "Data" that
/// shares its VML drawing and embedded parts.
fn rich_package_with(content_types: &str, twin: bool) -> Vec<u8> {
let workbook = format!(
r#"<?xml version="1.0"?><workbook xmlns="{NS_MAIN}" xmlns:r="{NS_R}"><sheets><sheet name="Data" sheetId="1" r:id="rId1"/><sheet name="Other" sheetId="2" r:id="rId2"/></sheets><externalReferences><externalReference r:id="rId3"/></externalReferences><definedNames><definedName name="ExternalData_1" localSheetId="0" hidden="1">Data!$E$1:$E$2</definedName></definedNames></workbook>"#
);
let sheet1 = format!(
r#"<?xml version="1.0"?><worksheet xmlns="{NS_MAIN}" xmlns:r="{NS_R}" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:x14="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" mc:Ignorable="x14"><sheetData><row r="1"><c r="A1"><f>[1]Sheet1!$A$1</f><v>7</v></c><c r="B1"><f>SUM([1]Sheet1!A1:A3)*2</f><v>14</v></c></row></sheetData><pageMargins left="0.7" right="0.7" top="0.75" bottom="0.75" header="0.3" footer="0.3"/><pageSetup orientation="landscape" r:id="rId1"/><legacyDrawing r:id="rId2"/><mc:AlternateContent><mc:Choice Requires="x14"><oleObjects><mc:AlternateContent><mc:Choice Requires="x14"><oleObject progId="Package" shapeId="1025" r:id="rId3"><objectPr defaultSize="0" r:id="rId4"><anchor moveWithCells="1"><from><xdr:col>1</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>1</xdr:row><xdr:rowOff>0</xdr:rowOff></from><to><xdr:col>3</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>5</xdr:row><xdr:rowOff>0</xdr:rowOff></to></anchor></objectPr></oleObject></mc:Choice><mc:Fallback><oleObject progId="Package" shapeId="1025" r:id="rId3"/></mc:Fallback></mc:AlternateContent></oleObjects></mc:Choice></mc:AlternateContent><controls><control shapeId="1026" r:id="rId5" name="Check Box 1"/></controls></worksheet>"#
);
let sheet2 = if twin {
sheet1.clone()
} else {
format!(r#"<?xml version="1.0"?><worksheet xmlns="{NS_MAIN}"><sheetData><row r="1"><c r="A1"><v>1</v></c></row></sheetData></worksheet>"#)
};
let root_rels = rels(&[("rId1", "officeDocument", "xl/workbook.xml"), ("rId2", "custom-properties", "docProps/custom.xml")]);
let wb_rels = rels(&[
("rId1", "worksheet", "worksheets/sheet1.xml"),
("rId2", "worksheet", "worksheets/sheet2.xml"),
("rId3", "externalLink", "externalLinks/externalLink1.xml"),
("rId4", "customXml", "../customXml/item1.xml"),
("rId5", "connections", "connections.xml"),
]);
let sheet1_rels = rels(&[
("rId1", "printerSettings", "../printerSettings/printerSettings1.bin"),
("rId2", "vmlDrawing", "../drawings/vmlDrawing1.vml"),
("rId3", "oleObject", "../embeddings/oleObject1.bin"),
("rId4", "image", "../media/image1.emf"),
("rId5", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/ctrlProp", "../ctrlProps/ctrlProp1.xml"),
("rId6", "queryTable", "../queryTables/queryTable1.xml"),
("rId7", "comments", "../comments1.xml"),
]);
let custom_rels = rels(&[("rId1", "customXmlProps", "itemProps1.xml")]);
let link_rels = rels(&[("rId1", "externalLinkPath", "file:///C:/never/opened/Book2.xlsx")]);
let vml_rels = rels(&[("rId1", "image", "../media/image1.emf")]);
let item_props = r#"<?xml version="1.0"?><ds:datastoreItem ds:itemID="{11111111-2222-3333-4444-555555555555}" xmlns:ds="http://schemas.openxmlformats.org/officeDocument/2006/customXml"/>"#;
let mut files: Vec<(&str, &[u8])> = vec![
("[Content_Types].xml", content_types.as_bytes()),
("_rels/.rels", root_rels.as_bytes()),
("docProps/custom.xml", CUSTOM_PROPS.as_bytes()),
("xl/workbook.xml", workbook.as_bytes()),
("xl/_rels/workbook.xml.rels", wb_rels.as_bytes()),
("xl/worksheets/sheet1.xml", sheet1.as_bytes()),
("xl/worksheets/sheet2.xml", sheet2.as_bytes()),
("xl/worksheets/_rels/sheet1.xml.rels", sheet1_rels.as_bytes()),
("xl/printerSettings/printerSettings1.bin", PRINTER),
("xl/drawings/vmlDrawing1.vml", VML.as_bytes()),
("xl/drawings/_rels/vmlDrawing1.vml.rels", vml_rels.as_bytes()),
("xl/embeddings/oleObject1.bin", OLE),
("xl/media/image1.emf", EMF),
("xl/ctrlProps/ctrlProp1.xml", CTRL_PROP.as_bytes()),
("xl/queryTables/queryTable1.xml", QUERY_TABLE.as_bytes()),
("xl/comments1.xml", COMMENTS.as_bytes()),
("customXml/item1.xml", CUSTOM_XML.as_bytes()),
("customXml/_rels/item1.xml.rels", custom_rels.as_bytes()),
("customXml/itemProps1.xml", item_props.as_bytes()),
("xl/externalLinks/externalLink1.xml", EXTERNAL_LINK.as_bytes()),
("xl/externalLinks/_rels/externalLink1.xml.rels", link_rels.as_bytes()),
("xl/connections.xml", CONNECTIONS.as_bytes()),
];
if twin {
files.push(("xl/worksheets/_rels/sheet2.xml.rels", sheet1_rels.as_bytes()));
}
make_zip(&files)
}
/// An unpacked package: parts by name, content types, resolved relationships.
struct Unpacked {
parts: HashMap<String, Vec<u8>>,
}
/// (id, type, resolved target, external)
type RelList = Vec<(String, String, String, bool)>;
impl Unpacked {
fn new(bytes: &[u8]) -> Unpacked {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut parts = HashMap::new();
for i in 0..z.len() {
let mut f = z.by_index(i).unwrap();
let mut v = vec![];
f.read_to_end(&mut v).unwrap();
parts.insert(f.name().to_string(), v);
}
Unpacked { parts }
}
fn text(&self, name: &str) -> String {
String::from_utf8(self.parts.get(name).unwrap_or_else(|| panic!("no part {name}")).clone()).unwrap()
}
fn rels(&self, part: &str) -> RelList {
let (dir, file) = split_dir(part);
let name = if dir.is_empty() { format!("_rels/{file}.rels") } else { format!("{dir}/_rels/{file}.rels") };
let Some(b) = self.parts.get(&name) else { return vec![] };
let root = xml::parse(b).unwrap();
root.kids("Relationship")
.map(|r| {
let ext = r.attr("TargetMode") == Some("External");
let t = r.attr("Target").unwrap();
(r.attr("Id").unwrap().into(), r.attr("Type").unwrap().into(), if ext { t.into() } else { resolve(dir, t) }, ext)
})
.collect()
}
fn target(&self, part: &str, id: &str) -> (String, String) {
let r = self.rels(part).into_iter().find(|r| r.0 == id).unwrap_or_else(|| panic!("{part}: no relationship {id}"));
(r.1, r.2)
}
/// Every part has a content type, every internal relationship target exists, rel ids are
/// unique per part.
fn assert_valid(&self) {
let types = xml::parse(self.parts.get("[Content_Types].xml").unwrap()).unwrap();
let defaults: Vec<String> = types.kids("Default").map(|d| d.attr("Extension").unwrap().to_ascii_lowercase()).collect();
let overrides: Vec<String> = types.kids("Override").map(|d| normalize(d.attr("PartName").unwrap())).collect();
let mut seen_override = std::collections::HashSet::new();
for o in &overrides {
assert!(seen_override.insert(o.clone()), "duplicate override {o}");
assert!(self.parts.keys().any(|p| normalize(p) == *o), "override for a missing part {o}");
}
for name in self.parts.keys().filter(|n| *n != "[Content_Types].xml") {
let ext = name.rsplit('.').next().unwrap().to_ascii_lowercase();
assert!(overrides.contains(&normalize(name)) || defaults.contains(&ext), "{name} has no content type");
if name.ends_with(".rels") {
let source = name.replace("_rels/", "").trim_end_matches(".rels").to_string();
let rels = self.rels(&source);
let mut ids = std::collections::HashSet::new();
for (id, _, target, ext) in rels {
assert!(ids.insert(id.clone()), "{name}: duplicate id {id}");
if !ext {
assert!(self.parts.keys().any(|p| normalize(p) == normalize(&target)), "{name}: {id} → missing {target}");
}
}
}
}
}
/// The part holding exactly `data`.
fn find(&self, data: &[u8]) -> Vec<&String> {
self.parts.iter().filter(|(_, v)| v.as_slice() == data).map(|(k, _)| k).collect()
}
}
/// The value of attribute `attr` of the first `<tag …>` in `x` (`r:id` of `<pageSetup`).
fn attr_of(x: &str, tag: &str, attr: &str) -> String {
let i = x.find(&format!("<{tag} ")).unwrap_or_else(|| panic!("no <{tag}> in {x}"));
let head = &x[i..i + x[i..].find('>').unwrap()];
let j = head.find(&format!(" {attr}=\"")).unwrap_or_else(|| panic!("no {attr} in {head}")) + attr.len() + 3;
head[j..j + head[j..].find('"').unwrap()].to_string()
}
fn sheet_part(u: &Unpacked, sheet: &str) -> String {
let wb = u.text("xl/workbook.xml");
let i = wb.find(&format!("<sheet name=\"{sheet}\"")).unwrap();
let id = attr_of(&wb[i..], "sheet", "r:id");
u.target("xl/workbook.xml", &id).1
}
#[test]
fn unmodelled_parts_survive_a_round_trip() {
let (wb, rep) = read_xlsx(&rich_package()).unwrap();
assert!(wb.preserved.is_some(), "{:?}", rep.warnings);
let data = wb.sheet(0).unwrap();
assert!(data.preserved.is_some(), "{:?}", rep.warnings);
assert!(!rep.warnings.iter().any(|w| w.contains("dropped")), "{:?}", rep.warnings);
assert_eq!(data.comments.len(), 1, "the note is modelled");
let out = write_xlsx(&wb).unwrap();
let u = Unpacked::new(&out);
u.assert_valid();
// Every kept part is in the output byte for byte.
for d in [
PRINTER,
OLE,
EMF,
CUSTOM_XML.as_bytes(),
CUSTOM_PROPS.as_bytes(),
EXTERNAL_LINK.as_bytes(),
CONNECTIONS.as_bytes(),
QUERY_TABLE.as_bytes(),
CTRL_PROP.as_bytes(),
] {
assert_eq!(u.find(d).len(), 1, "kept part missing or duplicated: {}", String::from_utf8_lossy(d));
}
// Package root: custom properties.
assert!(u.rels("").iter().any(|r| r.1.ends_with("/custom-properties") && u.parts.get(&r.2).is_some_and(|b| b == CUSTOM_PROPS.as_bytes())));
// Workbook: external link in <externalReferences>, its path kept as an external target.
let wbx = u.text("xl/workbook.xml");
let link_id = attr_of(&wbx, "externalReference", "r:id");
let (ty, link) = u.target("xl/workbook.xml", &link_id);
assert!(ty.ends_with("/externalLink"));
assert_eq!(u.parts.get(&link).unwrap(), EXTERNAL_LINK.as_bytes());
let path = u.rels(&link);
assert_eq!(path.len(), 1);
assert_eq!((path[0].0.as_str(), path[0].2.as_str(), path[0].3), ("rId1", "file:///C:/never/opened/Book2.xlsx", true));
assert!(wbx.find("</sheets>").unwrap() < wbx.find("<externalReferences").unwrap());
assert!(wbx.find("<externalReferences").unwrap() < wbx.find("<definedNames").unwrap());
// The custom XML item keeps its properties part.
let item = u.find(CUSTOM_XML.as_bytes())[0].clone();
assert!(u.rels(&item).iter().any(|r| r.1.ends_with("/customXmlProps")));
// Formulas using the link are written as they were.
let sheet = sheet_part(&u, "Data");
let sx = u.text(&sheet);
assert!(sx.contains("<f>[1]Sheet1!$A$1</f>"), "{sx}");
assert!(sx.contains("<f>SUM([1]Sheet1!A1:A3)*2</f>"), "{sx}");
// Printer settings (#324).
let ps = attr_of(&sx, "pageSetup", "r:id");
let (ty, target) = u.target(&sheet, &ps);
assert!(ty.ends_with("/printerSettings"));
assert_eq!(u.parts.get(&target).unwrap(), PRINTER);
// The OLE object and its preview image, re-linked.
let ole = attr_of(&sx, "oleObject", "r:id");
assert_eq!(u.parts.get(&u.target(&sheet, &ole).1).unwrap(), OLE);
let preview = attr_of(&sx, "objectPr", "r:id");
assert_eq!(u.parts.get(&u.target(&sheet, &preview).1).unwrap(), EMF);
// Namespace declarations travel with the fragment.
assert_eq!(attr_of(&sx, "mc:AlternateContent", "xmlns:mc"), "http://schemas.openxmlformats.org/markup-compatibility/2006");
assert_eq!(attr_of(&sx, "mc:AlternateContent", "xmlns:x14"), "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main");
// The form control.
let ctrl = attr_of(&sx, "control", "r:id");
assert_eq!(u.parts.get(&u.target(&sheet, &ctrl).1).unwrap(), CTRL_PROP.as_bytes());
// Schema order: pageSetup < legacyDrawing < oleObjects < controls.
let pos = |t: &str| sx.find(t).unwrap_or_else(|| panic!("no {t}"));
assert!(pos("<pageSetup") < pos("<legacyDrawing") && pos("<legacyDrawing") < pos("<oleObjects") && pos("<oleObjects") < pos("<controls"));
// Query table: a sheet relationship only.
assert!(u.rels(&sheet).iter().any(|r| r.1.ends_with("/queryTable") && u.parts.get(&r.2).is_some_and(|b| b == QUERY_TABLE.as_bytes())));
// One VML drawing: the kept control shapes plus GridCraft's note, in a fresh shape block.
let vml_id = attr_of(&sx, "legacyDrawing", "r:id");
let vml_part = u.target(&sheet, &vml_id).1;
let vml = u.text(&vml_part);
assert!(vml.contains("_x0000_s1025") && vml.contains("_x0000_s1026"));
assert!(!vml.contains("old note") && !vml.contains("_x0000_s1027"), "the source's note shape is replaced");
assert_eq!(vml.matches("ObjectType=\"Note\"").count(), 1);
assert!(vml.contains("_x0000_s2049") && vml.contains("data=\"1,2\""), "{vml}");
let img = u.rels(&vml_part);
assert_eq!(img.len(), 1);
assert_eq!(u.parts.get(&img[0].2).unwrap(), EMF);
// Reading the output keeps the same content again.
let (wb2, rep2) = read_xlsx(&out).unwrap();
assert!(!rep2.warnings.iter().any(|w| w.contains("dropped")), "{:?}", rep2.warnings);
let a = wb.sheet(0).unwrap().preserved.as_deref().unwrap();
let b = wb2.sheet(0).unwrap().preserved.as_deref().unwrap();
let mut da: Vec<&[u8]> = a.parts.iter().map(|p| &*p.data).collect();
let mut db: Vec<&[u8]> = b.parts.iter().map(|p| &*p.data).collect();
da.sort();
db.sort();
assert_eq!(da, db);
assert_eq!(a.elements.len(), b.elements.len());
assert_eq!(wb.preserved.as_deref().unwrap().parts.len(), wb2.preserved.as_deref().unwrap().parts.len());
u.assert_valid();
}
#[test]
fn edited_workbooks_keep_consistent_relationships() {
let (mut wb, _) = read_xlsx(&rich_package()).unwrap();
// Insert a sheet before, rename the sheet with controls, drop its note, edit a cell.
wb.sheets.insert(0, Arc::new(Sheet::new("New")));
let s = wb.sheet_mut(1).unwrap();
s.name = "Renamed".into();
s.comments.clear();
s.set_value(gridcraft_core::CellRef::new(5, 5), gridcraft_core::Value::Number(3.0));
let u = Unpacked::new(&write_xlsx(&wb).unwrap());
u.assert_valid();
let sheet = sheet_part(&u, "Renamed");
assert_eq!(sheet, "xl/worksheets/sheet2.xml");
let sx = u.text(&sheet);
assert_eq!(u.parts.get(&u.target(&sheet, &attr_of(&sx, "pageSetup", "r:id")).1).unwrap(), PRINTER);
assert_eq!(u.parts.get(&u.target(&sheet, &attr_of(&sx, "control", "r:id")).1).unwrap(), CTRL_PROP.as_bytes());
// Without notes the kept VML is written on its own, still without the old note.
let vml = u.text(&u.target(&sheet, &attr_of(&sx, "legacyDrawing", "r:id")).1);
assert!(vml.contains("_x0000_s1026") && !vml.contains("ObjectType=\"Note\""));
assert!(!u.text("xl/worksheets/sheet1.xml").contains("pageSetup orientation=\"landscape\" r:id"));
// Deleting the sheet drops its content; workbook-level content stays.
wb.sheets.remove(1);
wb.names.clear();
let u = Unpacked::new(&write_xlsx(&wb).unwrap());
u.assert_valid();
for d in [PRINTER, OLE, EMF, QUERY_TABLE.as_bytes(), CTRL_PROP.as_bytes()] {
assert!(u.find(d).is_empty());
}
for d in [CUSTOM_XML.as_bytes(), EXTERNAL_LINK.as_bytes(), CONNECTIONS.as_bytes(), CUSTOM_PROPS.as_bytes()] {
assert_eq!(u.find(d).len(), 1);
}
}
/// #324: an openpyxl-style workbook whose sheet carries printer settings.
#[test]
fn printer_settings_and_page_setup_id_survive_save() {
let ct = r#"<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="bin" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.printerSettings"/><Default Extension="xml" ContentType="application/xml"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/></Types>"#;
let bin: Vec<u8> = (0..=255u8).cycle().take(1024).collect();
let pkg = super::minimal(
r#"<sheetData><row r="1"><c r="A1"><v>42.1</v></c></row></sheetData><pageMargins left="0.75" right="0.75" top="1" bottom="1" header="0.5" footer="0.5"/><pageSetup orientation="landscape" r:id="rIdPs1"/>"#,
&[("xl/worksheets/_rels/sheet1.xml.rels", &rels(&[("rIdPs1", "printerSettings", "../printerSettings/printerSettings1.bin")]))],
"",
"",
);
// Swap in content types that know `.bin`, and add the binary part.
let mut u = Unpacked::new(&pkg);
u.parts.insert("[Content_Types].xml".into(), ct.as_bytes().to_vec());
u.parts.insert("xl/printerSettings/printerSettings1.bin".into(), bin.clone());
let list: Vec<(&str, &[u8])> = u.parts.iter().map(|(k, v)| (k.as_str(), v.as_slice())).collect();
let (wb, rep) = read_xlsx(&make_zip(&list)).unwrap();
assert!(rep.warnings.iter().all(|w| !w.contains("printer")), "{:?}", rep.warnings);
let out = Unpacked::new(&write_xlsx(&wb).unwrap());
out.assert_valid();
let sx = out.text("xl/worksheets/sheet1.xml");
assert!(sx.contains("orientation=\"landscape\""));
let (ty, target) = out.target("xl/worksheets/sheet1.xml", &attr_of(&sx, "pageSetup", "r:id"));
assert!(ty.ends_with("/printerSettings"));
assert_eq!(target, "xl/printerSettings/printerSettings1.bin");
assert_eq!(out.parts.get(&target).unwrap(), &bin);
}
#[test]
fn damaged_kept_content_is_dropped_without_failing_the_read() {
let ct = CONTENT_TYPES.replace("</Types>", r#"<Override PartName="/customXml/item2.xml" ContentType="application/xml"/></Types>"#);
let workbook = format!(
r#"<?xml version="1.0"?><workbook xmlns="{NS_MAIN}" xmlns:r="{NS_R}"><sheets><sheet name="Data" sheetId="1" r:id="rId1"/></sheets><externalReferences><externalReference r:id="rId3"/><externalReference r:id="rId99"/></externalReferences></workbook>"#
);
let sheet = format!(
r#"<?xml version="1.0"?><worksheet xmlns="{NS_MAIN}" xmlns:r="{NS_R}"><sheetData/><pageSetup r:id="rId1"/><controls><control shapeId="1" r:id="rId404" name="Broken"/></controls></worksheet>"#
);
let wb_rels = rels(&[
("rId1", "worksheet", "worksheets/sheet1.xml"),
("rId3", "externalLink", "externalLinks/externalLink1.xml"),
// A missing part.
("rId4", "customXml", "../customXml/missing.xml"),
// A cycle: item1 → itemProps1 → item1.
("rId5", "customXml", "../customXml/item1.xml"),
// Kept content that points into a part GridCraft rewrites.
("rId6", "customXml", "../customXml/item2.xml"),
// Damaged rels behind a kept part.
("rId7", "connections", "connections.xml"),
]);
let cyclic = rels(&[("rId1", "customXmlProps", "itemProps1.xml")]);
let back = rels(&[("rId1", "customXml", "item1.xml")]);
let into_sheet = rels(&[("rId1", "worksheet", "../xl/worksheets/sheet1.xml")]);
let sheet_rels = rels(&[("rId1", "printerSettings", "../printerSettings/missing.bin")]);
let bytes = make_zip(&[
("[Content_Types].xml", ct.as_bytes()),
("_rels/.rels", super::ROOT_RELS.as_bytes()),
("xl/workbook.xml", workbook.as_bytes()),
("xl/_rels/workbook.xml.rels", wb_rels.as_bytes()),
("xl/worksheets/sheet1.xml", sheet.as_bytes()),
("xl/worksheets/_rels/sheet1.xml.rels", sheet_rels.as_bytes()),
("xl/externalLinks/externalLink1.xml", EXTERNAL_LINK.as_bytes()),
("customXml/item1.xml", CUSTOM_XML.as_bytes()),
("customXml/_rels/item1.xml.rels", cyclic.as_bytes()),
("customXml/itemProps1.xml", b"<p/>"),
("customXml/_rels/itemProps1.xml.rels", back.as_bytes()),
("customXml/item2.xml", b"<q/>"),
("customXml/_rels/item2.xml.rels", into_sheet.as_bytes()),
("xl/connections.xml", CONNECTIONS.as_bytes()),
("xl/_rels/connections.xml.rels", b"<Relationships"),
]);
let (wb, rep) = read_xlsx(&bytes).unwrap();
let w = rep.warnings.join("\n");
assert!(w.contains("customXml/missing.xml is missing"), "{w}");
assert!(w.contains("refers to worksheet"), "{w}");
assert!(w.contains("links to external workbooks were dropped"), "a link without a part drops all links: {w}");
assert!(w.contains("printer settings were dropped"), "{w}");
assert!(w.contains("dropped: the sheet has no VML drawing"), "{w}");
let p = wb.preserved.as_deref().unwrap();
// Only the cyclic custom XML pair is consistent enough to keep.
let mut names: Vec<&str> = p.parts.iter().map(|q| q.name.as_str()).collect();
names.sort();
assert_eq!(names, ["customXml/item1.xml", "customXml/itemProps1.xml"]);
assert!(p.elements.is_empty());
assert!(wb.sheet(0).unwrap().preserved.is_none());
let u = Unpacked::new(&write_xlsx(&wb).unwrap());
u.assert_valid();
assert!(!u.text("xl/workbook.xml").contains("externalReferences"));
}
#[test]
fn oversized_kept_parts_are_dropped() {
let big = vec![0u8; crate::passthrough::MAX_KEPT_PART as usize + 1];
let ct = CONTENT_TYPES;
let wb_rels = rels(&[("rId1", "worksheet", "worksheets/sheet1.xml"), ("rId2", "customXml", "../customXml/item1.xml")]);
let workbook = format!(
r#"<?xml version="1.0"?><workbook xmlns="{NS_MAIN}" xmlns:r="{NS_R}"><sheets><sheet name="A" sheetId="1" r:id="rId1"/></sheets></workbook>"#
);
let sheet = format!(r#"<?xml version="1.0"?><worksheet xmlns="{NS_MAIN}"><sheetData/></worksheet>"#);
let bytes = make_zip(&[
("[Content_Types].xml", ct.as_bytes()),
("_rels/.rels", super::ROOT_RELS.as_bytes()),
("xl/workbook.xml", workbook.as_bytes()),
("xl/_rels/workbook.xml.rels", wb_rels.as_bytes()),
("xl/worksheets/sheet1.xml", sheet.as_bytes()),
("customXml/item1.xml", &big),
]);
drop(big);
let (wb, rep) = read_xlsx(&bytes).unwrap();
assert!(wb.preserved.is_none());
assert!(rep.warnings.iter().any(|w| w.contains("customXml") && w.contains("dropped")), "{:?}", rep.warnings);
}
#[test]
fn kept_content_types_are_escaped_on_save() {
// A content type holding `"` and `<` (legal once unescaped by the XML reader) must not break
// out of the attribute in the saved [Content_Types].xml.
let hostile = r#"application/x-test&quot; PartName=&quot;/x&quot;/&gt;&lt;Default Extension=&quot;evil"#;
let ct = CONTENT_TYPES.replace("application/vnd.openxmlformats-officedocument.customXmlProperties+xml", hostile);
let (wb, rep) = read_xlsx(&rich_package_with(&ct, false)).unwrap();
assert!(wb.preserved.is_some(), "{:?}", rep.warnings);
let u = Unpacked::new(&write_xlsx(&wb).unwrap());
let types = xml::parse(u.text("[Content_Types].xml").as_bytes()).unwrap();
assert!(!types.kids("Default").any(|d| d.attr("Extension") == Some("evil")));
let item = types.kids("Override").find(|o| o.attr("PartName") == Some("/customXml/itemProps1.xml")).unwrap();
assert_eq!(item.attr("ContentType"), Some(r#"application/x-test" PartName="/x"/><Default Extension="evil"#));
// And the saved file reads back.
read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
}
#[test]
fn kept_vml_drawings_are_charged_to_the_kept_total() {
// How much one sheet's kept content costs, with the default allowance.
let one = {
let (wb, rep) = read_xlsx(&rich_package()).unwrap();
assert!(!rep.warnings.iter().any(|w| w.contains("dropped")), "{:?}", rep.warnings);
wb.preserved.as_ref().map_or(0, |p| p.byte_len()) + wb.sheets.iter().filter_map(|s| s.preserved.as_ref()).map(|p| p.byte_len()).sum::<usize>()
};
// Two sheets share one VML drawing: each would keep its own copy. With room for one sheet's
// content only, the second sheet's VML (and the controls drawn from it) is dropped.
crate::passthrough::KEPT_TOTAL_FOR_TEST.with(|c| c.set(one + VML.len() / 2));
let read = read_xlsx(&rich_package_with(CONTENT_TYPES, true));
crate::passthrough::KEPT_TOTAL_FOR_TEST.with(|c| c.set(crate::passthrough::MAX_KEPT_TOTAL));
let (wb, rep) = read.unwrap();
let kept = wb.preserved.as_ref().map_or(0, |p| p.byte_len())
+ wb.sheets.iter().filter_map(|s| s.preserved.as_ref()).map(|p| p.byte_len()).sum::<usize>();
assert!(kept <= one + VML.len() / 2, "kept {kept} bytes");
assert!(wb.sheet(0).unwrap().preserved.as_ref().is_some_and(|p| p.vml.is_some()));
assert!(!wb.sheet(1).unwrap().preserved.as_ref().is_some_and(|p| p.vml.is_some()));
assert!(rep.warnings.iter().any(|w| w.contains("\"Other\"") && w.contains("too large")), "{:?}", rep.warnings);
// Both sheets keep their drawings when there is room.
let (wb, _) = read_xlsx(&rich_package_with(CONTENT_TYPES, true)).unwrap();
assert!(wb.sheets.iter().all(|s| s.preserved.as_ref().is_some_and(|p| p.vml.is_some())));
}

View File

@ -22,6 +22,7 @@ fn sample() -> Workbook {
wb.date_system = DateSystem::D1904;
wb.calc = CalcSettings { mode: CalcMode::Manual, iterative: true, max_iterations: 42, max_change: 0.5, precision_as_displayed: true };
wb.protected_structure = true;
wb.structure_password = Some(gridcraft_model::PasswordHash::new_sha512("s3cret", b"0123456789abcdef"));
wb.props = DocProps {
title: "T & <x>".into(),
subject: "S".into(),
@ -247,7 +248,14 @@ fn sample() -> Workbook {
(3, FilterCriterion::FillColor(Color::Rgb(0xFF0000))),
],
});
s.protection = Some(SheetProtection { format_cells: true, sort: true, select_locked: true, select_unlocked: true, ..Default::default() });
s.protection = Some(SheetProtection {
format_cells: true,
sort: true,
select_locked: true,
select_unlocked: true,
password: Some(gridcraft_model::PasswordHash::new_sha512("sheet pw", b"fedcba9876543210")),
..Default::default()
});
s.print = PrintSettings {
orientation: Orientation::Landscape,
paper: "A4".into(),
@ -265,6 +273,14 @@ fn sample() -> Workbook {
footer: "&C&F".into(),
row_breaks: vec![15, 30],
col_breaks: vec![4],
different_first: true,
different_odd_even: true,
first_header: "&CCover".into(),
first_footer: String::new(),
even_header: "&LEven &P".into(),
even_footer: "&R&A".into(),
hf_scale_with_doc: false,
hf_align_with_margins: true,
};
s.sparklines.push(Sparkline { cell: at("J2"), source: "B2:B3".into(), kind: SparklineKind::WinLoss, color: Color::Rgb(0x112233), markers: true });
s.tables.push(Table {
@ -301,6 +317,7 @@ fn sample() -> Workbook {
fill: Color::Rgb(0x4472C4),
line: Color::Rgb(0x000000),
text: "Hi\nthere".into(),
format: Default::default(),
});
s.shapes.push(Shape {
id: 12,
@ -309,6 +326,7 @@ fn sample() -> Workbook {
fill: Color::Auto,
line: Color::Rgb(0xFF0000),
text: String::new(),
format: Default::default(),
});
s.shapes.push(Shape {
id: 13,
@ -317,6 +335,17 @@ fn sample() -> Workbook {
fill: Color::Auto,
line: Color::Auto,
text: "box".into(),
format: ShapeFormat {
no_line: true,
font_size: Some(20.0),
bold: true,
italic: true,
font: Some("Arial".into()),
text_color: Some(Color::Rgb(0x112233)),
text_outline: Some((Color::Rgb(0xFF0000), 1.5)),
align: Some(ShapeTextAlign::Right),
anchor: Some(ShapeTextAnchor::Bottom),
},
});
let base_chart = Chart {
id: 20,
@ -412,6 +441,8 @@ fn full_roundtrip() {
assert_eq!(back.date_system, wb.date_system);
assert_eq!(back.calc, wb.calc);
assert!(back.protected_structure);
assert_eq!(back.structure_password, wb.structure_password);
assert!(back.structure_password.as_ref().unwrap().verify("s3cret").unwrap());
assert_eq!(back.props, wb.props);
assert_eq!(back.theme, wb.theme);
assert_eq!(back.names, wb.names);
@ -480,7 +511,7 @@ fn full_roundtrip() {
assert_eq!(b.shapes[1].anchor.mode, AnchorMode::MoveAndSize, "default anchor mode");
assert_eq!(b.shapes.len(), 3);
for (x, y) in a.shapes.iter().zip(&b.shapes) {
assert_eq!((x.kind, &x.text, x.fill, x.line), (y.kind, &y.text, y.fill, y.line));
assert_eq!((x.kind, &x.text, x.fill, x.line, &x.format), (y.kind, &y.text, y.fill, y.line, &y.format));
}
assert_eq!(b.charts.len(), a.charts.len());
for (x, y) in a.charts.iter().zip(&b.charts) {
@ -621,6 +652,56 @@ fn writing_repairs_invalid_models() {
assert_eq!(s.cell(at("B2")).unwrap().style, StyleId::DEFAULT);
}
#[test]
fn vba_project_roundtrip() {
// A VBA project blob is preserved byte-identically through write and read, and the package
// carries the macro-enabled content type plus the vbaProject relationship.
let vba = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1 fake OLE2 vbaProject".to_vec();
let mut wb = Workbook::new();
wb.vba_project = Some(vba.clone());
let bytes = write_xlsx(&wb).unwrap();
assert_eq!(write_xlsx(&wb).unwrap(), bytes, "output is deterministic");
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes.clone())).unwrap();
let names: Vec<String> = (0..z.len()).map(|i| z.name_for_index(i).unwrap().to_string()).collect();
assert!(names.iter().any(|x| x == "xl/vbaProject.bin"), "missing vbaProject.bin");
let mut bin = vec![];
z.by_name("xl/vbaProject.bin").unwrap().read_to_end(&mut bin).unwrap();
assert_eq!(bin, vba, "vbaProject.bin written byte-identically");
let mut ct = String::new();
z.by_name("[Content_Types].xml").unwrap().read_to_string(&mut ct).unwrap();
assert!(ct.contains("macroEnabled.main+xml"), "macro-enabled main content type: {ct}");
assert!(ct.contains("/xl/vbaProject.bin"), "vbaProject content type override: {ct}");
let mut rels = String::new();
z.by_name("xl/_rels/workbook.xml.rels").unwrap().read_to_string(&mut rels).unwrap();
assert!(rels.contains("relationships/vbaProject"), "vbaProject relationship: {rels}");
let (back, rep) = read_xlsx(&bytes).unwrap();
assert_eq!(back.vba_project.as_deref(), Some(vba.as_slice()), "vbaProject survives the read");
assert!(rep.warnings.iter().any(|w| w.contains("not executed")), "{:?}", rep.warnings);
// A workbook without macros stays macro-free and free of VBA warnings.
let (plain, rp) = read_xlsx(&write_xlsx(&Workbook::new()).unwrap()).unwrap();
assert_eq!(plain.vba_project, None);
assert!(rp.warnings.iter().all(|w| !w.contains("VBA")), "{:?}", rp.warnings);
}
#[test]
fn vba_project_read_from_foreign_file() {
// An .xlsm built by another producer is captured on read (not dropped with a plain warning).
let vba: &[u8] = b"PK\x03\x04-not-really-but-opaque-bytes";
let bytes = super::minimal(
"<sheetData/>",
&[("xl/vbaProject.bin", "ignored-as-text")],
r#"<Relationship Id="rId9" Type="http://schemas.microsoft.com/office/2006/relationships/vbaProject" Target="vbaProject.bin"/>"#,
"",
);
let (wb, rep) = read_xlsx(&bytes).unwrap();
assert!(wb.vba_project.is_some(), "VBA blob captured");
assert!(rep.warnings.iter().any(|w| w.contains("not executed")), "{:?}", rep.warnings);
let _ = vba;
}
fn sheet1_xml(bytes: &[u8]) -> (String, bool) {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut s = String::new();
@ -763,3 +844,28 @@ fn bad_comment_dates_are_left_out() {
let c = &back.sheets[0].comments[&at("A1")];
assert!(c.threaded && c.dates.is_empty());
}
/// #378: the saved scroll position survives a round trip, with and without frozen panes (where it
/// is the scrolling pane's `topLeftCell`).
#[test]
fn scroll_position_round_trips_with_and_without_frozen_panes() {
let mut wb = Workbook::new();
wb.sheet_mut(0).unwrap().view_top_left = at("A31");
let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
assert_eq!(back.sheet(0).unwrap().view_top_left, at("A31"));
let s = wb.sheet_mut(0).unwrap();
s.freeze = Some((1, 0));
s.view_top_left = at("C40");
let bytes = write_xlsx(&wb).unwrap();
let (xml, _) = sheet1_xml(&bytes);
assert!(xml.contains(r#"<pane ySplit="1" topLeftCell="C40""#), "{xml}");
let (back, _) = read_xlsx(&bytes).unwrap();
assert_eq!(back.sheet(0).unwrap().view_top_left, at("C40"));
assert_eq!(back.sheet(0).unwrap().freeze, Some((1, 0)));
// A scroll position inside the frozen rows is written as the first scrolling row.
wb.sheet_mut(0).unwrap().view_top_left = at("A1");
let (xml, _) = sheet1_xml(&write_xlsx(&wb).unwrap());
assert!(xml.contains(r#"topLeftCell="A2""#), "{xml}");
}

View File

@ -0,0 +1,478 @@
//! Legacy `.xls` (BIFF8) import. Every test file is assembled here from the [MS-CFB] and
//! [MS-XLS] structures; no Excel-produced file is used.
use gridcraft_core::{CellError, CellRef, DateSystem, RangeRef, Value};
use crate::{Format, IoError, read_xls, read_xlsx, sniff, write_xlsx};
const END: u32 = 0xFFFF_FFFE;
const FREE: u32 = 0xFFFF_FFFF;
fn put32(b: &mut [u8], at: usize, v: u32) {
b[at..at + 4].copy_from_slice(&v.to_le_bytes());
}
fn dir_entry(name: &str, kind: u8, child: u32, start: u32, size: u32) -> [u8; 128] {
let mut e = [0u8; 128];
let units: Vec<u16> = name.encode_utf16().collect();
for (i, u) in units.iter().enumerate() {
e[i * 2..i * 2 + 2].copy_from_slice(&u.to_le_bytes());
}
if !name.is_empty() {
e[64..66].copy_from_slice(&((units.len() as u16 + 1) * 2).to_le_bytes());
}
e[66] = kind;
e[67] = 1; // black
put32(&mut e, 68, FREE);
put32(&mut e, 72, FREE);
put32(&mut e, 76, child);
put32(&mut e, 116, start);
put32(&mut e, 120, size);
e
}
/// A version 3 compound file (512-byte sectors) whose root holds one stream. Streams under
/// 4096 bytes go to the mini stream, like real files.
pub(crate) fn compound(name: &str, data: &[u8]) -> Vec<u8> {
let mini = data.len() < 4096;
let pad = |n: usize, to: usize| n.div_ceil(to) * to;
// Sector payloads after the FAT: directory, mini FAT (if any), then data.
let mut payload: Vec<Vec<u8>> = vec![];
let mut chains: Vec<(usize, usize)> = vec![]; // (first payload index, sector count)
let (dir_at, minifat_at, data_at);
let mut dir = vec![];
let mut minifat = vec![];
let stream_sectors;
if mini {
let m = data.len().div_ceil(64).max(1);
for i in 0..m {
minifat.extend_from_slice(&(if i + 1 == m { END } else { i as u32 + 1 }).to_le_bytes());
}
minifat.resize(512, 0xFF);
let mut container = data.to_vec();
container.resize(pad(data.len().max(1), 64), 0);
stream_sectors = container.len().div_ceil(512);
container.resize(stream_sectors * 512, 0);
dir_at = 0;
minifat_at = Some(1);
data_at = 2;
payload.push(vec![]);
payload.push(minifat);
payload.extend(container.chunks(512).map(<[u8]>::to_vec));
chains.push((data_at, stream_sectors));
dir.extend_from_slice(&dir_entry("Root Entry", 5, 1, 0, (stream_sectors * 512) as u32));
dir.extend_from_slice(&dir_entry(name, 2, FREE, 0, data.len() as u32));
} else {
let mut body = data.to_vec();
stream_sectors = body.len().div_ceil(512);
body.resize(stream_sectors * 512, 0);
dir_at = 0;
minifat_at = None;
data_at = 1;
payload.push(vec![]);
payload.extend(body.chunks(512).map(<[u8]>::to_vec));
chains.push((data_at, stream_sectors));
dir.extend_from_slice(&dir_entry("Root Entry", 5, 1, END, 0));
dir.extend_from_slice(&dir_entry(name, 2, FREE, 0, data.len() as u32));
}
dir.extend_from_slice(&dir_entry("", 0, FREE, FREE, 0));
dir.extend_from_slice(&dir_entry("", 0, FREE, FREE, 0));
// FAT sectors come first; their count depends on the total.
let mut fat_n = 1;
while (fat_n + payload.len()).div_ceil(128) > fat_n {
fat_n += 1;
}
let sec = |i: usize| (fat_n + i) as u32;
// Fix the directory's start sectors now that the layout is known.
let first_data = sec(data_at);
if mini {
put32(&mut dir, 116, first_data); // root: mini stream container
} else {
put32(&mut dir, 128 + 116, first_data);
}
payload[dir_at] = dir;
let total = fat_n + payload.len();
let mut fat = vec![FREE; fat_n * 128];
for f in fat.iter_mut().take(fat_n) {
*f = 0xFFFF_FFFD;
}
fat[sec(dir_at) as usize] = END;
if let Some(m) = minifat_at {
fat[sec(m) as usize] = END;
}
for (first, n) in chains {
for k in 0..n {
fat[sec(first + k) as usize] = if k + 1 == n { END } else { sec(first + k + 1) };
}
}
let mut out = vec![0u8; 512];
out[..8].copy_from_slice(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]);
out[24..26].copy_from_slice(&0x3Eu16.to_le_bytes());
out[26..28].copy_from_slice(&3u16.to_le_bytes());
out[28..30].copy_from_slice(&0xFFFEu16.to_le_bytes());
out[30..32].copy_from_slice(&9u16.to_le_bytes());
out[32..34].copy_from_slice(&6u16.to_le_bytes());
put32(&mut out, 44, fat_n as u32);
put32(&mut out, 48, sec(dir_at));
put32(&mut out, 56, 4096);
put32(&mut out, 60, minifat_at.map_or(END, sec));
put32(&mut out, 64, u32::from(minifat_at.is_some()));
put32(&mut out, 68, END);
for i in 0..109 {
put32(&mut out, 76 + i * 4, if i < fat_n { i as u32 } else { FREE });
}
for f in &fat {
out.extend_from_slice(&f.to_le_bytes());
}
for p in payload {
let mut p = p;
p.resize(512, 0);
out.extend_from_slice(&p);
}
assert_eq!(out.len(), 512 * (1 + total));
out
}
fn rec(id: u16, payload: &[u8]) -> Vec<u8> {
let mut v = id.to_le_bytes().to_vec();
v.extend_from_slice(&(payload.len() as u16).to_le_bytes());
v.extend_from_slice(payload);
v
}
fn cat(parts: &[&[u8]]) -> Vec<u8> {
parts.concat()
}
fn u16s(v: &[u16]) -> Vec<u8> {
v.iter().flat_map(|x| x.to_le_bytes()).collect()
}
/// Cell header: row, column, XF index.
fn cell(r: u16, c: u16, xf: u16) -> Vec<u8> {
u16s(&[r, c, xf])
}
fn bof(dt: u16) -> Vec<u8> {
rec(0x0809, &cat(&[&u16s(&[0x0600, dt, 0x0DBB, 0x07CC]), &[0; 8]]))
}
fn eof() -> Vec<u8> {
rec(0x000A, &[])
}
/// XLUnicodeString with compressed characters.
fn xlstr(s: &str) -> Vec<u8> {
cat(&[&(s.len() as u16).to_le_bytes(), &[0], s.as_bytes()])
}
/// A FORMULA record with a numeric cached result.
fn formula(r: u16, c: u16, cached: [u8; 8], rgce: &[u8]) -> Vec<u8> {
let mut p = cell(r, c, 0);
p.extend_from_slice(&cached);
p.extend_from_slice(&[0, 0, 0, 0, 0, 0]); // flags, chn
p.extend_from_slice(&(rgce.len() as u16).to_le_bytes());
p.extend_from_slice(rgce);
rec(0x0006, &p)
}
fn num(n: f64) -> [u8; 8] {
n.to_le_bytes()
}
fn special(kind: u8, v: u8) -> [u8; 8] {
[kind, 0, v, 0, 0, 0, 0xFF, 0xFF]
}
/// PtgRef (value class) with relative row and column.
fn ptg_ref(r: u16, c: u16) -> Vec<u8> {
cat(&[&[0x44], &u16s(&[r, c | 0xC000])])
}
fn ptg_int(n: u16) -> Vec<u8> {
cat(&[&[0x1E], &n.to_le_bytes()])
}
fn exp(r: u16, c: u16) -> Vec<u8> {
cat(&[&[0x01], &u16s(&[r, c])])
}
/// A workbook with two worksheets and a chart sheet covering the stage-1 features.
fn sample(date1904: bool) -> Vec<u8> {
// SST: "Hello", then "Wörld" as UTF-16 split across a Continue record.
let mut sst = cat(&[&2u32.to_le_bytes(), &2u32.to_le_bytes(), &xlstr("Hello")]);
sst.extend_from_slice(&[5, 0, 0, b'W']);
let sst_rec = cat(&[&rec(0x00FC, &sst), &rec(0x003C, &cat(&[&[1], &u16s(&[0xF6, 'r' as u16, 'l' as u16, 'd' as u16])]))]);
let xf = |ifmt: u16| rec(0x00E0, &cat(&[&u16s(&[0, ifmt, 0]), &[0; 14]]));
// Lbl "Rate" = Data!$A$1 (PtgRef3d, absolute).
let rate = cat(&[&[0x3A], &1u16.to_le_bytes(), &u16s(&[0, 0])]);
let lbl = rec(0x0018, &cat(&[&u16s(&[0]), &[0, 4], &u16s(&[rate.len() as u16, 0, 0]), &[0; 4], &[0], b"Rate", &rate]));
// Print_Area on Sheet1 (built-in name 6, itab 1) = Sheet1!$A$1:$B$2.
let pa = cat(&[&[0x3B], &0u16.to_le_bytes(), &u16s(&[0, 1, 0, 1])]);
let print_area = rec(0x0018, &cat(&[&u16s(&[0x0020]), &[0, 1], &u16s(&[pa.len() as u16, 0, 1]), &[0; 4], &[0, 6], &pa]));
let boundsheet = |pos: u32, kind: u8, name: &str| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, kind, name.len() as u8, 0], name.as_bytes()]));
let globals = |positions: [u32; 3]| {
cat(&[
&bof(0x0005),
&rec(0x0022, &u16s(&[u16::from(date1904)])),
&rec(0x041E, &cat(&[&164u16.to_le_bytes(), &xlstr("yyyy-mm-dd")])),
&xf(0),
&xf(164),
&xf(10), // built-in 0.00%
&boundsheet(positions[0], 0, "Sheet1"),
&boundsheet(positions[1], 0, "Data"),
&boundsheet(positions[2], 2, "Chart1"),
&rec(0x01AE, &u16s(&[3, 0x0401])),
&rec(0x0017, &cat(&[&u16s(&[2]), &u16s(&[0, 0, 0]), &u16s(&[0, 1, 1])])),
&lbl,
&print_area,
&sst_rec,
&eof(),
])
};
let mulrk = cat(&[&u16s(&[1, 2]), &u16s(&[0]), &((7u32 << 2) | 2).to_le_bytes(), &u16s(&[2]), &((150u32 << 2) | 3).to_le_bytes(), &u16s(&[3])]);
let shared = cat(&[&[0x4C], &u16s(&[0, 0xFFFF]), &ptg_int(10), &[0x05]]);
let array = cat(&[&[0x65], &u16s(&[4, 5, 0xC000, 0xC000]), &ptg_int(2), &[0x05]]);
let sum = cat(&[&[0x25], &u16s(&[0, 0, 1 | 0xC000, 2 | 0xC000]), &[0x22, 1], &4u16.to_le_bytes()]);
let concat = cat(&[&ptg_ref(0, 0), &[0x17, 1, 0, b'!'], &[0x08]]);
let round = cat(&[&ptg_ref(0, 1), &ptg_int(0), &[0x41], &27u16.to_le_bytes()]);
let name_ref = cat(&[&[0x43], &1u32.to_le_bytes(), &ptg_int(2), &[0x05]]);
let ref3d = cat(&[&[0x5A], &1u16.to_le_bytes(), &u16s(&[0, 0xC000])]);
let sheet1 = cat(&[
&bof(0x0010),
&rec(0x0225, &u16s(&[0, 255])),
&rec(0x007D, &u16s(&[0, 0, 20 * 256, 0, 0x0002, 0])),
&rec(0x0208, &cat(&[&u16s(&[0, 0, 4, 600, 0, 0]), &(0x0140u32).to_le_bytes()])),
&rec(0x00FD, &cat(&[&cell(0, 0, 0), &0u32.to_le_bytes()])),
&rec(0x0203, &cat(&[&cell(0, 1, 0), &1.5f64.to_le_bytes()])),
&rec(0x027E, &cat(&[&cell(0, 2, 0), &((42u32 << 2) | 2).to_le_bytes()])),
&rec(0x00FD, &cat(&[&cell(0, 3, 0), &1u32.to_le_bytes()])),
&rec(0x0205, &cat(&[&cell(1, 0, 0), &[1, 0]])),
&rec(0x0205, &cat(&[&cell(1, 1, 0), &[0x07, 1]])),
&rec(0x00BD, &mulrk),
&formula(2, 0, num(3.0), &cat(&[&ptg_ref(0, 1), &ptg_int(2), &[0x05]])),
&formula(2, 1, num(43.5), &sum),
&formula(2, 2, special(0, 0), &concat),
&rec(0x0207, &xlstr("Hello!")),
&formula(2, 3, special(1, 1), &cat(&[&ptg_ref(0, 1), &ptg_int(1), &[0x0D]])),
&formula(2, 4, num(14.0), &name_ref),
&formula(2, 5, num(2.0), &round),
&formula(2, 6, num(7.0), &ref3d),
&formula(2, 7, special(2, 0x2A), &[0x1C, 0x2A]),
&rec(0x0203, &cat(&[&cell(3, 0, 1), &0.0f64.to_le_bytes()])),
&rec(0x0203, &cat(&[&cell(3, 1, 2), &0.25f64.to_le_bytes()])),
&rec(0x027E, &cat(&[&cell(4, 0, 0), &((1u32 << 2) | 2).to_le_bytes()])),
&rec(0x027E, &cat(&[&cell(5, 0, 0), &((2u32 << 2) | 2).to_le_bytes()])),
&formula(4, 1, num(10.0), &exp(4, 1)),
&rec(0x04BC, &cat(&[&u16s(&[4, 5]), &[1, 1, 0, 2], &(shared.len() as u16).to_le_bytes(), &shared])),
&formula(5, 1, num(20.0), &exp(4, 1)),
&formula(4, 2, num(2.0), &exp(4, 2)),
&rec(0x0221, &cat(&[&u16s(&[4, 5]), &[2, 2], &[0; 6], &(array.len() as u16).to_le_bytes(), &array])),
&formula(5, 2, num(4.0), &exp(4, 2)),
&rec(0x00E5, &cat(&[&u16s(&[1]), &u16s(&[6, 6, 0, 1])])),
// An embedded chart substream: its records are not cells.
&bof(0x0020),
&rec(0x0203, &cat(&[&cell(9, 9, 0), &9.0f64.to_le_bytes()])),
&eof(),
&eof(),
]);
let data = cat(&[&bof(0x0010), &rec(0x0203, &cat(&[&cell(0, 0, 0), &7.0f64.to_le_bytes()])), &eof()]);
let chart = cat(&[&bof(0x0020), &eof()]);
let len = globals([0; 3]).len() as u32;
let p1 = len;
let p2 = p1 + sheet1.len() as u32;
let p3 = p2 + data.len() as u32;
cat(&[&globals([p1, p2, p3]), &sheet1, &data, &chart])
}
fn v(wb: &gridcraft_model::Workbook, sheet: usize, a1: &str) -> Value {
wb.sheets[sheet].value(CellRef::parse(a1).unwrap())
}
fn ftext(wb: &gridcraft_model::Workbook, a1: &str) -> Option<String> {
wb.sheets[0].cell(CellRef::parse(a1).unwrap()).and_then(|c| c.formula.as_ref()).map(|f| f.text.clone())
}
#[test]
fn reads_values_formulas_formats_and_layout() {
for big in [false, true] {
let mut stream = sample(false);
if big {
// Past the mini stream cutoff: the stream lives in regular sectors.
stream.extend(std::iter::repeat_n(0u8, 5000));
}
let bytes = compound("Workbook", &stream);
assert_eq!(sniff(&bytes), Format::Xls);
let (wb, report) = read_xls(&bytes).unwrap();
assert!(report.warnings[0].contains("Excel 97-2003"), "{:?}", report.warnings);
assert!(report.warnings.iter().any(|w| w.contains("Chart sheets")));
assert_eq!(wb.sheets.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["Sheet1", "Data"]);
assert_eq!(v(&wb, 0, "A1"), Value::from("Hello"));
assert_eq!(v(&wb, 0, "B1"), Value::Number(1.5));
assert_eq!(v(&wb, 0, "C1"), Value::Number(42.0));
assert_eq!(v(&wb, 0, "D1"), Value::from("Wörld"));
assert_eq!(v(&wb, 0, "A2"), Value::Bool(true));
assert_eq!(v(&wb, 0, "B2"), Value::Error(CellError::Div0));
assert_eq!(v(&wb, 0, "C2"), Value::Number(7.0));
assert_eq!(v(&wb, 0, "D2"), Value::Number(1.5));
// Formulas: text plus the cached result.
assert_eq!(ftext(&wb, "A3").as_deref(), Some("B1*2"));
assert_eq!(v(&wb, 0, "A3"), Value::Number(3.0));
assert_eq!(ftext(&wb, "B3").as_deref(), Some("SUM(B1:C1)"));
assert_eq!(ftext(&wb, "C3").as_deref(), Some("A1&\"!\""));
assert_eq!(v(&wb, 0, "C3"), Value::from("Hello!"));
assert_eq!(ftext(&wb, "D3").as_deref(), Some("B1>1"));
assert_eq!(v(&wb, 0, "D3"), Value::Bool(true));
assert_eq!(ftext(&wb, "E3").as_deref(), Some("Rate*2"));
assert_eq!(ftext(&wb, "F3").as_deref(), Some("ROUND(B1,0)"));
assert_eq!(ftext(&wb, "G3").as_deref(), Some("Data!A1"));
assert_eq!(v(&wb, 0, "H3"), Value::Error(CellError::NA));
// Shared formula: relative offsets resolve per cell.
assert_eq!(ftext(&wb, "B5").as_deref(), Some("A5*10"));
assert_eq!(ftext(&wb, "B6").as_deref(), Some("A6*10"));
// Array formula: the anchor holds it, the other cell keeps its value.
let c5 = wb.sheets[0].cell(CellRef::parse("C5").unwrap()).unwrap().formula.clone().unwrap();
assert_eq!((c5.text.as_str(), c5.array), ("A5:A6*2", Some(RangeRef::parse("C5:C6").unwrap())));
assert_eq!(ftext(&wb, "C6"), None);
assert_eq!(v(&wb, 0, "C6"), Value::Number(4.0));
// Number formats, merges, sizes, names, print area; embedded chart records ignored.
let sh = &wb.sheets[0];
assert_eq!(wb.styles.get(sh.style_id(CellRef::parse("A4").unwrap())).num_fmt.as_str(), "yyyy-mm-dd");
assert_eq!(wb.styles.get(sh.style_id(CellRef::parse("B4").unwrap())).num_fmt.as_str(), "0.00%");
assert_eq!(sh.merges, vec![RangeRef::parse("A7:B7").unwrap()]);
assert!(sh.cols.get(&0).unwrap().size.unwrap() > sh.default_col_width);
assert_eq!(sh.rows.get(&0).unwrap().size, Some(40.0));
assert!(!sh.rows.get(&0).unwrap().hidden && sh.rows.get(&0).unwrap().custom);
assert_eq!(sh.print.print_area, RangeRef::parse("A1:B2"));
assert_eq!(v(&wb, 0, "J10"), Value::Empty);
assert_eq!(wb.names.len(), 1);
assert_eq!((wb.names[0].name.as_str(), wb.names[0].formula.as_str()), ("Rate", "Data!$A$1"));
assert_eq!(v(&wb, 1, "A1"), Value::Number(7.0));
assert_eq!(wb.date_system, DateSystem::D1900);
// The import saves as .xlsx and reads back.
let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
assert_eq!(back.sheets[0].cell(CellRef::parse("A3").unwrap()).unwrap().formula.as_ref().unwrap().text, "B1*2");
assert_eq!(back.sheets[0].value(CellRef::parse("C3").unwrap()), Value::from("Hello!"));
}
let (wb, _) = read_xls(&compound("Workbook", &sample(true))).unwrap();
assert_eq!(wb.date_system, DateSystem::D1904);
}
#[test]
fn refuses_encrypted_old_and_foreign_files() {
// FILEPASS in the globals: an encrypted workbook.
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}");
// 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);
assert!(read_xls(&book).unwrap_err().to_string().contains("BIFF5"));
// Another compound file (a word processor document): not a workbook.
let doc = compound("WordDocument", &[0u8; 100]);
assert_eq!(sniff(&doc), Format::LegacyBinary);
assert!(read_xls(&doc).is_err());
}
#[test]
fn hostile_xls_input_never_panics_or_hangs() {
let stream = sample(false);
let good = compound("Workbook", &stream);
// Truncation anywhere.
for cut in (0..good.len()).step_by(37) {
let _ = read_xls(&good[..cut]);
let _ = sniff(&good[..cut]);
}
// Byte mutations in the container and the stream.
for at in (0..good.len()).step_by(7) {
for x in [0x00u8, 0x01, 0x7F, 0xFF] {
let mut b = good.clone();
b[at] = x;
let _ = read_xls(&b);
}
}
for at in 0..stream.len() {
let mut s = stream.clone();
s[at] ^= 0xA5;
let _ = read_xls(&compound("Workbook", &s));
}
// A FAT chain that loops on itself (big stream in regular sectors).
let mut big = stream.clone();
big.extend(std::iter::repeat_n(0u8, 5000));
let mut b = compound("Workbook", &big);
let fat_at = 512;
let first_data = 2usize; // FAT, directory, then data
put32(&mut b, fat_at + (first_data + 3) * 4, first_data as u32);
assert!(read_xls(&b).is_err());
// Huge declared counts: shared strings, merges, external sheets.
let mut huge = bof(0x0005);
huge.extend(rec(0x00FC, &cat(&[&u32::MAX.to_le_bytes(), &u32::MAX.to_le_bytes()])));
huge.extend(eof());
assert!(matches!(read_xls(&compound("Workbook", &huge)), Err(IoError::TooLarge(_))));
let mut huge = bof(0x0005);
huge.extend(rec(0x00FC, &cat(&[&9u32.to_le_bytes(), &1_000_000u32.to_le_bytes(), &xlstr("x")])));
huge.extend(rec(0x0017, &u16s(&[0xFFFF])));
huge.extend(eof());
assert!(read_xls(&compound("Workbook", &huge)).is_err()); // no worksheets
// A sheet pointing outside the stream and a stream size larger than the file.
let mut bad = bof(0x0005);
bad.extend(rec(0x0085, &cat(&[&u32::MAX.to_le_bytes(), &[0, 0, 1, 0], b"S"])));
bad.extend(eof());
assert!(read_xls(&compound("Workbook", &bad)).is_err());
let mut b = compound("Workbook", &big);
put32(&mut b, 512 * 2 + 128 + 120, 0x3FFF_FFFF);
assert!(read_xls(&b).is_err());
// Random bytes behind a compound-file signature.
let mut seed = 0x2545_F491_4F6C_DD1Du64;
for _ in 0..200 {
let mut b = good[..512].to_vec();
for _ in 0..2048 {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
b.push(seed as u8);
}
let _ = read_xls(&b);
}
}
#[test]
fn sheets_share_one_record_budget() {
// Many BoundSheet records point into one long substream (at successive nested BOFs), so each
// sheet would read the whole tail. The record allowance is shared, not per sheet.
let n = 300u32;
let tail: Vec<u8> = (0..2000u16).flat_map(|r| rec(0x0203, &cat(&[&cell(r, 0, 0), &1.0f64.to_le_bytes()]))).collect();
let boundsheet = |pos: u32, name: &str| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, name.len() as u8, 0], name.as_bytes()]));
let globals = |base: u32| {
let mut g = bof(0x0005);
for i in 0..n {
g.extend(boundsheet(base + i * bof(0x0010).len() as u32, &format!("S{i}")));
}
g.extend(eof());
g
};
let base = globals(0).len() as u32;
let mut stream = globals(base);
for _ in 0..n {
stream.extend(bof(0x0010));
}
stream.extend(tail);
stream.extend(eof());
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_with(&file, crate::xls::Budget::with_max_records(100_000)), Err(IoError::TooLarge(_))));
assert!(crate::xls::read_xls_with(&file, crate::xls::Budget::with_max_records(10_000_000)).is_ok());
// Two sheets at the same offset are refused outright.
let mut dup = bof(0x0005);
let at = (dup.len() + 2 * boundsheet(0, "A").len() + eof().len()) as u32;
dup.extend(boundsheet(at, "A"));
dup.extend(boundsheet(at, "B"));
dup.extend(eof());
dup.extend(bof(0x0010));
dup.extend(eof());
let err = read_xls(&compound("Workbook", &dup)).unwrap_err().to_string();
assert!(err.contains("same substream"), "{err}");
}

View File

@ -20,6 +20,11 @@ pub const CT_DRAWING: &str = "application/vnd.openxmlformats-officedocument.draw
pub const CT_CHART: &str = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml";
pub const CT_TABLE: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml";
pub const CT_COMMENTS: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml";
/// Main workbook part: macro-free and macro-enabled variants (Excel refuses a `.xlsm` whose main
/// part uses the macro-free content type, and vice versa).
pub const CT_WORKBOOK_MAIN: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml";
pub const CT_WORKBOOK_MAIN_MACRO: &str = "application/vnd.ms-excel.sheet.macroEnabled.main+xml";
pub const CT_VBA: &str = "application/vnd.ms-office.vbaProject";
pub const CT_THREADED_COMMENTS: &str = "application/vnd.ms-excel.threadedcomments+xml";
pub const CT_PERSONS: &str = "application/vnd.ms-excel.person+xml";
pub const REL_THREADED_COMMENT: &str = "http://schemas.microsoft.com/office/2017/10/relationships/threadedComment";
@ -39,6 +44,9 @@ impl Rels {
let id = format!("rId{}", self.items.len() + 1);
let ty = match kind {
"core-properties" => "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties".to_string(),
// The VBA project relationship lives in the Microsoft-documented namespace, not the
// OpenXML one; Excel rejects the file if this type is wrong.
"vbaProject" => "http://schemas.microsoft.com/office/2006/relationships/vbaProject".to_string(),
k => format!("{NS_REL}/{k}"),
};
self.items.push((id.clone(), ty, target.to_string(), false));
@ -55,6 +63,12 @@ impl Rels {
self.items.push((id.clone(), ty.to_string(), target.to_string(), false));
id
}
/// Adds a relationship with its complete type URI, internal or external (kept content).
pub fn add_raw(&mut self, ty: &str, target: &str, external: bool) -> String {
let id = format!("rId{}", self.items.len() + 1);
self.items.push((id.clone(), crate::xml::esc_attr(ty), target.to_string(), external));
id
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
@ -114,6 +128,8 @@ pub struct Out {
pub dynamic_arrays: bool,
pub needs_calc: bool,
pub cell_pictures: crate::richdata::PicturesOut,
/// Kept (pass-through) parts written so far.
pub kept: crate::passthrough::KeptOut,
}
impl Out {
@ -173,6 +189,7 @@ pub fn write_xlsx(wb: &Workbook) -> Result<Vec<u8>, IoError> {
dynamic_arrays: false,
needs_calc: false,
cell_pictures: Default::default(),
kept: Default::default(),
};
out.default_type("rels", "application/vnd.openxmlformats-package.relationships+xml");
out.default_type("xml", "application/xml");
@ -259,9 +276,11 @@ pub fn write_xlsx(wb: &Workbook) -> Result<Vec<u8>, IoError> {
let mut w = format!("{XML_DECL}<workbook xmlns=\"{NS_MAIN}\" xmlns:r=\"{NS_REL}\">");
w.push_str(if wb.date_system == gridcraft_core::DateSystem::D1904 { "<workbookPr date1904=\"1\"/>" } else { "<workbookPr/>" });
if wb.protected_structure {
w.push_str("<workbookProtection lockStructure=\"1\"/>");
let pw = wb.structure_password.as_ref().map(|h| crate::sheet_write::password_attrs(h, "workbook")).unwrap_or_default();
let _ = write!(w, "<workbookProtection{pw} lockStructure=\"1\"/>");
}
let _ = write!(w, "<bookViews><workbookView activeTab=\"{active}\"/></bookViews><sheets>{sheets_xml}</sheets>");
w.push_str(&crate::passthrough::write_workbook(wb, &mut wb_rels, &mut out));
if !names.is_empty() {
let _ = write!(w, "<definedNames>{names}</definedNames>");
}
@ -327,7 +346,11 @@ pub fn write_xlsx(wb: &Workbook) -> Result<Vec<u8>, IoError> {
out.part("xl/persons/person.xml", Some(CT_PERSONS), x.into_bytes());
wb_rels.add_uri(REL_PERSON, "persons/person.xml");
}
out.part("xl/workbook.xml", Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"), w.into_bytes());
out.part("xl/workbook.xml", Some(if wb.vba_project.is_some() { CT_WORKBOOK_MAIN_MACRO } else { CT_WORKBOOK_MAIN }), w.into_bytes());
if let Some(data) = &wb.vba_project {
wb_rels.add("vbaProject", "vbaProject.bin");
out.part("xl/vbaProject.bin", Some(CT_VBA), data.clone());
}
out.part("xl/_rels/workbook.xml.rels", None, wb_rels.xml().into_bytes());
// Document properties.
@ -359,15 +382,17 @@ pub fn write_xlsx(wb: &Workbook) -> Result<Vec<u8>, IoError> {
root.add("officeDocument", "xl/workbook.xml");
root.add("core-properties", "docProps/core.xml");
root.add("extended-properties", "docProps/app.xml");
crate::passthrough::write_package_rels(&mut root, &mut out);
out.part("_rels/.rels", None, root.xml().into_bytes());
// Content types first, then the parts in creation order.
let mut ct = format!("{XML_DECL}<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">");
for (ext, t) in &out.defaults {
let _ = write!(ct, "<Default Extension=\"{ext}\" ContentType=\"{t}\"/>");
// Kept parts bring their content types (and extensions) from the input file: escape them.
let _ = write!(ct, "<Default Extension=\"{}\" ContentType=\"{}\"/>", esc_attr(ext), esc_attr(t));
}
for (part, t) in &out.overrides {
let _ = write!(ct, "<Override PartName=\"{}\" ContentType=\"{t}\"/>", esc_attr(part));
let _ = write!(ct, "<Override PartName=\"{}\" ContentType=\"{}\"/>", esc_attr(part), esc_attr(t));
}
ct.push_str("</Types>");
let mut zip = ZipOut::new();

View File

@ -0,0 +1,548 @@
//! 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>,
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 = t.u32()?;
let name = usize::try_from(i)
.ok()
.and_then(|i| i.checked_sub(1))
.and_then(|i| links.names.get(i))
.filter(|n| !n.is_empty())
.ok_or(Unsupported("reference to a missing defined name"))?;
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()],
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());
}
}

376
crates/xlsx/src/xls/ftab.rs Normal file
View File

@ -0,0 +1,376 @@
// 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),
];

786
crates/xlsx/src/xls/mod.rs Normal file
View File

@ -0,0 +1,786 @@
//! Legacy Excel 97-2003 `.xls` (BIFF8) import, implemented from Microsoft's public
//! specifications [MS-CFB] (the compound file container) and [MS-XLS] (the Workbook stream):
//! <https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-xls/cd03cb5f-ca02-4934-a391-bb674cb8aa06>.
//!
//! Read: worksheets, shared strings, cell values, formulas (text and cached results, including
//! shared and array formulas), number formats, the 1904 date system, merged cells, column
//! widths, row heights, defined names, print areas and print titles. Everything else (fonts,
//! fills, borders, charts, pictures, comments, conditional formats, validation, macros) is
//! skipped with a warning. Encrypted workbooks and pre-BIFF8 versions are refused.
mod formula;
mod ftab;
mod records;
use std::collections::HashMap;
use std::sync::Arc;
use gridcraft_core::{CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRef, Value};
use gridcraft_model::{Cell, DefinedName, Formula, LineInfo, NumFmt, Sheet, Style, StyleId, Visibility, Workbook};
use crate::cfb::Compound;
use crate::tables::{builtin_format, col_width_to_px, pt_to_px};
use crate::{IoError, ReadReport};
use formula::{Links, Mode, SupBook, Unsupported, Xti, cell_error, decode};
use records::{Cursor, Record, Records};
const MAX_CELLS: usize = 4_000_000;
const MAX_STRINGS: usize = 4_000_000;
const MAX_TEXT: usize = 256 * 1024 * 1024;
const MAX_SHEETS: usize = 4096;
const MAX_NAMES: usize = 65_536;
const MAX_XFS: usize = 65_536;
const MAX_MERGES: usize = 100_000;
/// Records read from the whole Workbook stream at most (a 65,536 × 256 sheet has fewer). One
/// allowance shared by every substream: many BoundSheet records pointing into one huge
/// substream must not each get their own.
const MAX_RECORDS: usize = 40_000_000;
const BOF: u16 = 0x0809;
const EOF: u16 = 0x000A;
const BIFF8: u16 = 0x0600;
pub(crate) fn invalid(msg: impl Into<String>) -> IoError {
IoError::Format(format!("XLS: {}", msg.into()))
}
/// The message for an encrypted workbook (a FilePass record).
const ENCRYPTED: &str =
"this .xls workbook is password-protected (encrypted). GridCraft can't open encrypted .xls files; remove the password in Excel and try again.";
/// Whether the bytes are a compound file holding an Excel workbook stream (BIFF8 `Workbook`
/// or BIFF5 `Book`).
pub(crate) fn is_xls(bytes: &[u8]) -> bool {
let Ok(cf) = Compound::open(bytes) else { return false };
cf.has_root_stream("Workbook") || cf.has_root_stream("Book")
}
pub(crate) struct Budget {
cells: usize,
text: usize,
records: usize,
max_records: usize,
}
impl Default for Budget {
fn default() -> Self {
Budget { cells: 0, text: 0, records: 0, max_records: MAX_RECORDS }
}
}
impl Budget {
/// A budget with a smaller record allowance (tests).
#[cfg(test)]
pub(crate) fn with_max_records(max_records: usize) -> Self {
Budget { max_records, ..Budget::default() }
}
fn record(&mut self) -> Result<(), IoError> {
self.records = self.records.saturating_add(1);
if self.records > self.max_records {
return Err(IoError::TooLarge("XLS: too many records".into()));
}
Ok(())
}
fn cell(&mut self) -> Result<(), IoError> {
self.cells += 1;
if self.cells > MAX_CELLS {
return Err(IoError::TooLarge("XLS: more than 4 million cells".into()));
}
Ok(())
}
fn text(&mut self, len: usize) -> Result<(), IoError> {
self.text = self.text.saturating_add(len);
if self.text > MAX_TEXT {
return Err(IoError::TooLarge("XLS: more than 256 MB of text".into()));
}
Ok(())
}
}
struct BoundSheet {
pos: usize,
name: String,
visibility: Visibility,
kind: u8,
}
struct Lbl {
name: String,
builtin: Option<u8>,
hidden: bool,
macro_name: bool,
itab: u16,
rgce: Vec<u8>,
rgcb: Vec<u8>,
}
#[derive(Default)]
struct Globals {
sheets: Vec<BoundSheet>,
date1904: bool,
formats: HashMap<u16, String>,
xf_formats: Vec<u16>,
sst: Vec<Arc<str>>,
links: Links,
names: Vec<Lbl>,
}
/// Reads an Excel 97-2003 workbook. Inspect the report's warnings for what was skipped.
pub fn read_xls(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
read_xls_with(bytes, Budget::default())
}
/// [`read_xls`] with an explicit resource budget.
pub(crate) fn read_xls_with(bytes: &[u8], mut budget: Budget) -> Result<(Workbook, ReadReport), IoError> {
let cf = Compound::open(bytes).map_err(invalid)?;
let stream = match cf.root_stream("Workbook").map_err(invalid)? {
Some(s) => s,
None => {
if cf.has_root_stream("Book") {
return Err(invalid("Excel 5.0/95 workbooks (BIFF5) are not supported; save the file as .xlsx in Excel and try again."));
}
return Err(invalid("the file has no Workbook stream, so it is not an Excel workbook"));
}
};
let mut report = ReadReport::default();
let g = globals(&stream, &mut budget)?;
let mut wb = Workbook::new();
wb.sheets.clear();
wb.date_system = if g.date1904 { DateSystem::D1904 } else { DateSystem::D1900 };
// One model style per XF: only the number format is imported.
let styles: Vec<StyleId> = g
.xf_formats
.iter()
.map(|ifmt| {
let code = g.formats.get(ifmt).map(String::as_str).or_else(|| builtin_format(u32::from(*ifmt))).unwrap_or("General");
if code.eq_ignore_ascii_case("General") {
StyleId::DEFAULT
} else {
wb.styles.intern(Style { num_fmt: NumFmt::new(code), ..Style::default() })
}
})
.collect();
// BoundSheet8 index → model sheet index.
let mut model_index: Vec<Option<usize>> = Vec::with_capacity(g.sheets.len());
for bs in &g.sheets {
if bs.kind != 0 {
report.warn(match bs.kind {
2 => "Chart sheets in .xls files are not imported yet and were skipped.",
1 => "Excel 4.0 macro sheets were skipped.",
_ => "VBA modules and other non-worksheet sheets were skipped.",
});
model_index.push(None);
continue;
}
wb.check_sheet_name(&bs.name, None).map_err(invalid)?;
let Some(mut sheet) = worksheet(&stream, bs, &g, &styles, &mut budget, &mut report)? else {
report.warn("Dialog sheets and unreadable sheets were skipped.");
model_index.push(None);
continue;
};
sheet.visibility = bs.visibility;
model_index.push(Some(wb.sheets.len()));
wb.sheets.push(Arc::new(sheet));
}
if wb.sheets.is_empty() {
return Err(invalid("the workbook has no worksheets GridCraft can read"));
}
defined_names(&g, &model_index, &mut wb, &mut report);
wb.active_sheet = wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0);
// The overview comes first.
report.warnings.insert(
0,
"Imported from an Excel 97-2003 (.xls) workbook: values, formulas, number formats, merged cells, column widths, row heights and defined names were read. Fonts, fills, borders, charts, pictures, comments and other features are not imported yet. Save as .xlsx; saving .xls is not supported.".into(),
);
Ok((wb, report))
}
fn bof(rec: &Record<'_>) -> Result<(u16, u16), IoError> {
if rec.id != BOF {
return Err(invalid("a substream does not start with a BOF record"));
}
let mut c = Cursor::new(&rec.parts);
Ok((c.u16()?, c.u16()?))
}
fn globals(stream: &[u8], budget: &mut Budget) -> Result<Globals, IoError> {
let mut recs = Records::new(stream, 0);
let first = recs.next_record()?.ok_or_else(|| invalid("empty Workbook stream"))?;
let (vers, dt) = bof(&first)?;
if vers != BIFF8 {
return Err(invalid(if vers == 0x0500 {
"Excel 5.0/95 workbooks (BIFF5) are not supported; save the file as .xlsx in Excel and try again."
} else {
"unsupported BIFF version (only Excel 97-2003 BIFF8 files are read)"
}));
}
if dt != 0x0005 {
return Err(invalid("the Workbook stream does not start with the workbook globals"));
}
let mut g = Globals::default();
let mut offsets = std::collections::HashSet::new();
while let Some(rec) = recs.next_record()? {
budget.record()?;
let mut c = Cursor::new(&rec.parts);
match rec.id {
EOF => break,
0x002F => return Err(invalid(ENCRYPTED)),
0x0085 => {
if g.sheets.len() >= MAX_SHEETS {
return Err(IoError::TooLarge("XLS: too many sheets".into()));
}
let pos = c.u32()? as usize;
if !offsets.insert(pos) {
return Err(invalid("two sheets point at the same substream"));
}
let state = c.u8()?;
let kind = c.u8()?;
let name = c.short_string()?;
let visibility = match state & 3 {
0 => Visibility::Visible,
1 => Visibility::Hidden,
_ => Visibility::VeryHidden,
};
g.links.sheets.push(name.clone());
g.sheets.push(BoundSheet { pos, name, visibility, kind });
}
0x0022 => g.date1904 = c.u16()? == 1,
0x041E => {
let ifmt = c.u16()?;
let code = c.string()?;
budget.text(code.len())?;
g.formats.insert(ifmt, code);
}
0x00E0 => {
if g.xf_formats.len() < MAX_XFS {
c.u16()?;
g.xf_formats.push(c.u16()?);
}
}
0x00FC => g.sst = shared_strings(&mut c, budget)?,
0x01AE => {
let _ctab = c.u16()?;
let cch = c.u16()?;
g.links.books.push(SupBook { internal: cch == 0x0401, addin: cch == 0x3A01, names: vec![] });
}
0x0023 => {
// ExternName: flags, then an AddinUdf or ExternDocName body; both start with
// four bytes before the name.
c.u16()?;
c.skip(4)?;
let name = c.short_string()?;
if let Some(book) = g.links.books.last_mut()
&& book.names.len() < MAX_NAMES
{
book.names.push(name);
}
}
0x0017 => {
let n = usize::from(c.u16()?);
for _ in 0..n.min(c.remaining() / 6) {
let book = c.u16()?;
let first = c.u16()? as i16;
let last = c.u16()? as i16;
g.links.xti.push(Xti { book, first, last });
}
}
0x0018 => {
if g.names.len() >= MAX_NAMES {
continue;
}
let lbl = label(&mut c)?;
g.links.names.push(lbl.name.clone());
g.names.push(lbl);
}
_ => {}
}
}
Ok(g)
}
/// Built-in defined names ([MS-XLS] 2.4.150, Lbl.Name).
const BUILTIN_NAMES: [&str; 14] = [
"Consolidate_Area",
"Auto_Open",
"Auto_Close",
"Extract",
"Database",
"Criteria",
"Print_Area",
"Print_Titles",
"Recorder",
"Data_Form",
"Auto_Activate",
"Auto_Deactivate",
"Sheet_Title",
"_FilterDatabase",
];
fn label(c: &mut Cursor<'_, '_>) -> Result<Lbl, IoError> {
let flags = c.u16()?;
let _key = c.u8()?;
let cch = usize::from(c.u8()?);
let cce = usize::from(c.u16()?);
c.skip(2)?;
let itab = c.u16()?;
c.skip(4)?;
let high = c.u8()? & 1 != 0;
let raw = c.chars(cch, high)?;
let builtin = flags & 0x0020 != 0;
let (name, builtin) = if builtin {
let code = raw.chars().next().map_or(0, |ch| ch as u32);
let code = u8::try_from(code).unwrap_or(u8::MAX);
(BUILTIN_NAMES.get(usize::from(code)).map_or_else(|| raw.clone(), |n| (*n).to_string()), Some(code))
} else {
(raw, None)
};
let rest = c.rest();
let rgce = rest.get(..cce).unwrap_or(&rest).to_vec();
let rgcb = rest.get(cce..).unwrap_or(&[]).to_vec();
Ok(Lbl { name, builtin, hidden: flags & 1 != 0, macro_name: flags & 0x000A != 0, itab, rgce, rgcb })
}
fn shared_strings(c: &mut Cursor<'_, '_>, budget: &mut Budget) -> Result<Vec<Arc<str>>, IoError> {
let _total = c.u32()?;
let unique = c.u32()? as usize;
if unique > MAX_STRINGS {
return Err(IoError::TooLarge("XLS: too many shared strings".into()));
}
// Each string takes at least three bytes: the declared count can't exceed what's there.
let mut out = Vec::with_capacity(unique.min(c.remaining() / 3));
for _ in 0..unique {
if c.remaining() == 0 {
break;
}
let s = c.rich_string()?;
budget.text(s.len())?;
out.push(Arc::from(s));
}
Ok(out)
}
/// A formula cell waiting for its shared or array formula (which follows it in the file).
struct Pending {
pos: CellRef,
anchor: CellRef,
}
/// A parsed formula: tokens and extra data.
struct Parsed {
rgce: Vec<u8>,
rgcb: Vec<u8>,
}
fn parsed(c: &mut Cursor<'_, '_>) -> Result<Parsed, IoError> {
let cce = usize::from(c.u16()?);
let rest = c.rest();
if cce > rest.len() {
return Err(invalid("formula runs past the end of its record"));
}
Ok(Parsed { rgce: rest.get(..cce).unwrap_or(&[]).to_vec(), rgcb: rest.get(cce..).unwrap_or(&[]).to_vec() })
}
struct SheetState<'g> {
g: &'g Globals,
styles: &'g [StyleId],
sheet: Sheet,
pending: Vec<Pending>,
shared: HashMap<CellRef, Parsed>,
arrays: HashMap<CellRef, (RangeRef, Parsed)>,
last_formula: Option<CellRef>,
pending_string: Option<CellRef>,
dropped: usize,
unsupported: Vec<&'static str>,
}
impl SheetState<'_> {
/// Records why a formula was kept as its value (each reason once).
fn unsupported(&mut self, why: &'static str) {
if !self.unsupported.contains(&why) {
self.unsupported.push(why);
}
}
fn style(&self, ixfe: u16) -> StyleId {
self.styles.get(usize::from(ixfe)).copied().unwrap_or(StyleId::DEFAULT)
}
fn pos(&mut self, row: u16, col: u16) -> Option<CellRef> {
let (r, c) = (u32::from(row), u32::from(col));
if r >= MAX_ROWS || c >= MAX_COLS.min(256) {
self.dropped += 1;
return None;
}
Some(CellRef::new(r, c))
}
fn put(&mut self, budget: &mut Budget, row: u16, col: u16, ixfe: u16, value: Value) -> Result<Option<CellRef>, IoError> {
let Some(pos) = self.pos(row, col) else { return Ok(None) };
let style = self.style(ixfe);
if value.is_empty() && style == StyleId::DEFAULT {
return Ok(Some(pos));
}
budget.cell()?;
self.sheet.cells.set(pos, Cell { value, formula: None, style });
Ok(Some(pos))
}
/// Decodes a formula and attaches it to the cell at `pos`, or records why it can't be.
fn attach(&mut self, pos: CellRef, p: &Parsed, mode: Mode, array: Option<RangeRef>) {
match decode(&p.rgce, &p.rgcb, pos, mode, &self.g.links) {
Ok(e) => {
let text = gridcraft_formula::print(&e);
let mut f = Formula::new(&text);
if f.expr.is_none() {
self.unsupported("syntax GridCraft can't parse yet");
return;
}
f.array = array.filter(|r| !r.is_single());
if let Some(cell) = self.sheet.cells.get_mut(pos) {
cell.formula = Some(Arc::new(f));
} else {
self.sheet.cells.set(pos, Cell { value: Value::Empty, formula: Some(Arc::new(f)), style: StyleId::DEFAULT });
}
}
Err(Unsupported(why)) => self.unsupported(why),
}
}
}
/// RK number ([MS-XLS] 2.5.217): a 30-bit integer or the top of an IEEE double, maybe ×100.
fn rk(v: u32) -> Value {
let n = if v & 2 != 0 { f64::from((v as i32) >> 2) } else { f64::from_bits(u64::from(v & !3) << 32) };
Value::number(if v & 1 != 0 { n / 100.0 } else { n })
}
fn worksheet(
stream: &[u8],
bs: &BoundSheet,
g: &Globals,
styles: &[StyleId],
budget: &mut Budget,
report: &mut ReadReport,
) -> Result<Option<Sheet>, IoError> {
if bs.pos >= stream.len() {
return Err(invalid(format!("sheet '{}' points outside the Workbook stream", bs.name)));
}
let mut recs = Records::new(stream, bs.pos);
let first = recs.next_record()?.ok_or_else(|| invalid("missing sheet substream"))?;
let (vers, dt) = bof(&first)?;
if vers != BIFF8 || dt != 0x0010 {
return Ok(None);
}
let mut st = SheetState {
g,
styles,
sheet: Sheet::new(&bs.name),
pending: vec![],
shared: HashMap::new(),
arrays: HashMap::new(),
last_formula: None,
pending_string: None,
dropped: 0,
unsupported: vec![],
};
let mut depth = 1usize;
while let Some(rec) = recs.next_record()? {
budget.record()?;
match rec.id {
BOF => {
// An embedded chart's substream.
depth += 1;
continue;
}
EOF => {
depth -= 1;
if depth == 0 {
break;
}
continue;
}
_ if depth > 1 => continue,
_ => {}
}
sheet_record(&rec, &mut st, budget, report)?;
}
// Shared and array formulas: their definitions follow the first cell that uses them.
let (arrays, shared) = (std::mem::take(&mut st.arrays), std::mem::take(&mut st.shared));
for p in std::mem::take(&mut st.pending) {
if let Some((range, f)) = arrays.get(&p.anchor) {
// The other cells of an array formula keep only their values.
if p.pos == p.anchor {
st.attach(p.pos, f, Mode::Cell, Some(*range));
}
} else if let Some(f) = shared.get(&p.anchor) {
st.attach(p.pos, f, Mode::Shared, None);
} else {
st.unsupported("a shared formula that is missing from the file");
}
}
if st.dropped > 0 {
report.warn(format!("{} cells outside the grid were skipped.", st.dropped));
}
for why in &st.unsupported {
report.warn(format!("Some formulas use {why}; their saved results were kept as values."));
}
Ok(Some(st.sheet))
}
fn sheet_record(rec: &Record<'_>, st: &mut SheetState<'_>, budget: &mut Budget, report: &mut ReadReport) -> Result<(), IoError> {
let mut c = Cursor::new(&rec.parts);
// A formula's string result is the String record right after it (shared/array records may sit between).
if !matches!(rec.id, 0x0207 | 0x04BC | 0x0221 | 0x0236) {
st.pending_string = None;
}
match rec.id {
0x0203 => {
let (r, col, x) = (c.u16()?, c.u16()?, c.u16()?);
let v = Value::number(c.f64()?);
st.put(budget, r, col, x, v)?;
}
0x027E => {
let (r, col, x) = (c.u16()?, c.u16()?, c.u16()?);
let v = rk(c.u32()?);
st.put(budget, r, col, x, v)?;
}
0x00BD => {
let r = c.u16()?;
let first = c.u16()?;
let n = c.remaining().saturating_sub(2) / 6;
for i in 0..n {
let x = c.u16()?;
let v = rk(c.u32()?);
let col = u16::try_from(usize::from(first) + i).unwrap_or(u16::MAX);
st.put(budget, r, col, x, v)?;
}
}
0x00FD => {
let (r, col, x) = (c.u16()?, c.u16()?, c.u16()?);
let i = c.u32()? as usize;
match st.g.sst.get(i) {
Some(s) => {
st.put(budget, r, col, x, Value::Text(s.clone()))?;
}
None => report.warn("Some cells refer to missing shared strings and were left empty."),
}
}
0x0204 => {
let (r, col, x) = (c.u16()?, c.u16()?, c.u16()?);
let s = c.string()?;
budget.text(s.len())?;
st.put(budget, r, col, x, Value::from(s))?;
}
0x0205 => {
let (r, col, x) = (c.u16()?, c.u16()?, c.u16()?);
let (v, is_err) = (c.u8()?, c.u8()?);
let v = if is_err != 0 { Value::Error(cell_error(v)) } else { Value::Bool(v != 0) };
st.put(budget, r, col, x, v)?;
}
0x0201 => {
let (r, col, x) = (c.u16()?, c.u16()?, c.u16()?);
st.put(budget, r, col, x, Value::Empty)?;
}
0x00BE => {
let r = c.u16()?;
let first = c.u16()?;
let n = c.remaining().saturating_sub(2) / 2;
for i in 0..n {
let x = c.u16()?;
let col = u16::try_from(usize::from(first) + i).unwrap_or(u16::MAX);
st.put(budget, r, col, x, Value::Empty)?;
}
}
0x0006 => formula_cell(&mut c, st, budget)?,
0x0207 => {
if let Some(pos) = st.pending_string.take() {
let s = c.string()?;
budget.text(s.len())?;
if let Some(cell) = st.sheet.cells.get_mut(pos) {
cell.value = Value::from(s);
}
}
}
0x04BC => {
// RefU (the cells that may use it), reserved, cUse.
c.skip(8)?;
let f = parsed(&mut c)?;
if let Some(at) = st.last_formula {
st.shared.insert(at, f);
}
}
0x0221 => {
let (r0, r1) = (c.u16()?, c.u16()?);
let (c0, c1) = (c.u8()?, c.u8()?);
c.skip(6)?;
let f = parsed(&mut c)?;
let range = RangeRef::new(CellRef::new(u32::from(r0), u32::from(c0)), CellRef::new(u32::from(r1.max(r0)), u32::from(c1.max(c0))));
if let Some(at) = st.last_formula {
st.arrays.insert(at, (range, f));
}
}
0x0236 => report.warn("What-if data tables are not supported; their values were kept."),
0x00E5 => {
let n = usize::from(c.u16()?);
for _ in 0..n.min(c.remaining() / 8) {
let (r0, r1, c0, c1) = (c.u16()?, c.u16()?, c.u16()?, c.u16()?);
if r1 < r0 || c1 < c0 || u32::from(c1) >= 256 {
continue;
}
if st.sheet.merges.len() >= MAX_MERGES {
return Err(IoError::TooLarge("XLS: too many merged ranges".into()));
}
let range = RangeRef::new(CellRef::new(u32::from(r0), u32::from(c0)), CellRef::new(u32::from(r1), u32::from(c1)));
if !range.is_single() {
st.sheet.merges.push(range);
}
}
}
0x007D => {
let (first, last) = (c.u16()?, c.u16()?);
let width = c.u16()?;
let x = c.u16()?;
let flags = c.u16()?;
let last = last.min(255);
if first > last {
return Ok(());
}
let mut info = LineInfo::default();
let px = col_width_to_px(f64::from(width) / 256.0);
if flags & 0x0002 != 0 || px != st.sheet.default_col_width {
info.size = Some(px);
}
info.hidden = flags & 0x0001 != 0;
info.outline = ((flags >> 8) & 7) as u8;
info.collapsed = flags & 0x1000 != 0;
let style = st.style(x);
if style != StyleId::DEFAULT {
info.style = Some(style);
}
if info != LineInfo::default() {
for col in first..=last {
st.sheet.cols.insert(u32::from(col), info);
}
}
}
0x0208 => {
let r = c.u16()?;
c.skip(4)?;
let height = c.u16()?;
c.skip(4)?;
let flags = c.u32()?;
let mut info = LineInfo { custom: flags & 0x40 != 0, hidden: flags & 0x20 != 0, ..LineInfo::default() };
let px = pt_to_px(f64::from(height & 0x7FFF) / 20.0);
if info.custom || (px - st.sheet.default_row_height).abs() > 0.5 {
info.size = Some(px);
}
info.outline = (flags & 7) as u8;
info.collapsed = flags & 0x10 != 0;
if flags & 0x80 != 0 {
let style = st.style(((flags >> 16) & 0x0FFF) as u16);
if style != StyleId::DEFAULT {
info.style = Some(style);
}
}
if info != LineInfo::default() && u32::from(r) < MAX_ROWS {
st.sheet.rows.insert(u32::from(r), info);
}
}
0x0225 => {
let flags = c.u16()?;
let height = c.u16()?;
if flags & 0x0002 == 0 && height > 0 {
st.sheet.default_row_height = pt_to_px(f64::from(height) / 20.0);
}
}
0x001C => report.warn("Cell comments in .xls files are not imported yet."),
0x00EC => report.warn("Pictures, charts and shapes in .xls files are not imported yet."),
0x01B0 | 0x0879 => report.warn("Conditional formatting in .xls files is not imported yet."),
0x01B2 => report.warn("Data validation in .xls files is not imported yet."),
0x01B8 => report.warn("Hyperlinks in .xls files are not imported yet."),
0x00B0 => report.warn("PivotTables in .xls files are not imported yet; their values were kept."),
_ => {}
}
Ok(())
}
fn formula_cell(c: &mut Cursor<'_, '_>, st: &mut SheetState<'_>, budget: &mut Budget) -> Result<(), IoError> {
let (r, col, x) = (c.u16()?, c.u16()?, c.u16()?);
let mut val = [0u8; 8];
for b in val.iter_mut() {
*b = c.u8()?;
}
let _flags = c.u16()?;
c.skip(4)?;
let f = parsed(c)?;
let [b0, _, b2, _, _, _, e0, e1] = val;
let mut string_result = false;
let value = if [e0, e1] == [0xFF, 0xFF] {
match b0 {
0x00 => {
string_result = true;
Value::Empty
}
0x01 => Value::Bool(b2 != 0),
0x02 => Value::Error(cell_error(b2)),
0x03 => Value::from(""),
_ => Value::Empty,
}
} else {
Value::number(f64::from_le_bytes(val))
};
let Some(pos) = st.pos(r, col) else { return Ok(()) };
budget.cell()?;
let style = st.style(x);
st.sheet.cells.set(pos, Cell { value, formula: None, style });
st.last_formula = Some(pos);
if string_result {
st.pending_string = Some(pos);
}
// PtgExp: part of a shared or array formula anchored at another cell.
if f.rgce.len() == 5 && f.rgce.first() == Some(&0x01) {
let get = |i: usize| f.rgce.get(i).copied().unwrap_or(0);
let anchor = CellRef::new(u32::from(u16::from_le_bytes([get(1), get(2)])), u32::from(u16::from_le_bytes([get(3), get(4)])));
st.pending.push(Pending { pos, anchor });
return Ok(());
}
st.attach(pos, &f, Mode::Cell, None);
Ok(())
}
fn defined_names(g: &Globals, model_index: &[Option<usize>], wb: &mut Workbook, report: &mut ReadReport) {
let scope_of = |itab: u16| -> Option<Option<usize>> {
if itab == 0 {
return Some(None);
}
model_index.get(usize::from(itab) - 1).copied().flatten().map(Some)
};
for lbl in &g.names {
let Some(scope) = scope_of(lbl.itab) else { continue };
let text = match decode(&lbl.rgce, &lbl.rgcb, CellRef::new(0, 0), Mode::Name, &g.links) {
Ok(e) => gridcraft_formula::print(&e),
Err(_) if lbl.rgce.is_empty() => continue,
Err(Unsupported(why)) => {
report.warn(format!("Some defined names use {why} and were skipped."));
continue;
}
};
match lbl.builtin {
Some(6 | 7) => {
let Some(si) = scope else { continue };
let Some(sheet) = wb.sheet_mut(si) else { continue };
if lbl.builtin == Some(6) {
sheet.print.print_area = crate::read::parse_print_area(&text);
} else {
let (rows, cols) = crate::read::parse_print_titles(&text);
sheet.print.title_rows = rows;
sheet.print.title_cols = cols;
}
}
Some(_) => {}
None if lbl.macro_name => report.warn("Macro names were skipped."),
None => wb.names.push(DefinedName { name: lbl.name.clone(), scope, formula: text, comment: String::new(), hidden: lbl.hidden }),
}
}
}

Some files were not shown because too many files have changed in this diff Show More