Merge upstream main into fix/formula-arg-count-363
Claude-Session: https://claude.ai/code/session_01BGBBGBC2gRpQ7GDuwQ6WPt
This commit is contained in:
commit
9b59966cc9
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
19
ROADMAP.md
19
ROADMAP.md
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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(×, 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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!({
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ¤t {
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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)}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) = §ion {
|
||||
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)) = §ion {
|
||||
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,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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::{
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 ページに印刷"),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" => "한 페이지에 시트 맞추기",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" => "Вписать лист на одну страницу",
|
||||
|
|
|
|||
|
|
@ -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" => "将工作表调整为一页",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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<_>>();
|
||||
|
|
|
|||
|
|
@ -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(¬ice.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)) = ¬ice.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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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(¯o_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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!({}));
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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("##"))));
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>&CTitle</oddHeader><oddFooter>Page &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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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" PartName="/x"/><Default Extension="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())));
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
];
|
||||
|
|
@ -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
Loading…
Reference in New Issue