From 9dc3aaf9fafd68f806e891c7aa07e37b2f3f7cee Mon Sep 17 00:00:00 2001 From: Harrison O'Connor-Hoover <130776285+harrisonoconnorhover@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:43:27 -0400 Subject: [PATCH] Support static pictures inside cells with XLSX roundtrip (#61) --- README.md | 1 + ROADMAP.md | 2 + apps/gridcraft-cli/src/main.rs | 8 + apps/gridcraft-web/src/web.rs | 12 + apps/gridcraft/src/main.rs | 7 + crates/engine/src/cmd/data.rs | 28 +- crates/engine/src/cmd/edit.rs | 51 ++-- crates/engine/src/cmd/extra.rs | 2 +- crates/engine/src/cmd/file.rs | 7 + crates/engine/src/cmd/format.rs | 2 +- crates/engine/src/cmd/insert.rs | 72 ++++- crates/engine/src/cmd/inspect.rs | 5 +- crates/engine/src/cmd/print.rs | 32 ++- crates/engine/src/cmd/sheet.rs | 8 +- crates/engine/src/cmd/whatif.rs | 20 +- crates/engine/src/fill.rs | 14 +- crates/engine/src/pivot.rs | 8 +- crates/engine/src/tests.rs | 180 ++++++++++++ crates/model/src/cell.rs | 10 + crates/model/src/lib.rs | 2 +- crates/model/src/sheet.rs | 44 ++- crates/ui-egui/src/cell_pictures.rs | 183 ++++++++++++ crates/ui-egui/src/dialogs.rs | 89 ++++-- crates/ui-egui/src/formula_bar.rs | 3 + crates/ui-egui/src/grid.rs | 4 + crates/ui-egui/src/lib.rs | 14 +- crates/ui-egui/src/ribbon.rs | 12 +- crates/ui-egui/tests/cell_pictures.rs | 156 ++++++++++ crates/xlsx/src/lib.rs | 1 + crates/xlsx/src/package.rs | 14 +- crates/xlsx/src/read.rs | 12 +- crates/xlsx/src/richdata.rs | 368 ++++++++++++++++++++++++ crates/xlsx/src/sheet_read.rs | 18 ++ crates/xlsx/src/sheet_write.rs | 9 +- crates/xlsx/src/tests/mod.rs | 1 + crates/xlsx/src/tests/richdata_tests.rs | 272 ++++++++++++++++++ crates/xlsx/src/write.rs | 15 +- docs/cell-pictures.md | 31 ++ docs/decisions.md | 28 ++ 39 files changed, 1659 insertions(+), 86 deletions(-) create mode 100644 crates/ui-egui/src/cell_pictures.rs create mode 100644 crates/ui-egui/tests/cell_pictures.rs create mode 100644 crates/xlsx/src/richdata.rs create mode 100644 crates/xlsx/src/tests/richdata_tests.rs create mode 100644 docs/cell-pictures.md create mode 100644 docs/decisions.md diff --git a/README.md b/README.md index 1344bc66d..b33135ed5 100644 --- a/README.md +++ b/README.md @@ -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. + [Static pictures can live inside cells](docs/cell-pictures.md), fitting their size and moving with the data. - **Capable.** A dependency-graph calculation engine with dynamic arrays and spilling, 500+ worksheet functions plus LET, LAMBDA, MAP, REDUCE, SCAN, BYROW, BYCOL and MAKEARRAY, structured table references, Excel's full number-format language, sort and AutoFilter, diff --git a/ROADMAP.md b/ROADMAP.md index 32ddbcd49..cfce5c749 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -94,6 +94,8 @@ by open equivalents or left out, not cloned. - PivotTables: Insert/Recommended PivotTables, field list pane, compact/outline/tabular layouts, date grouping, sort, filters, show-values-as, refresh, and XLSX round-trip. - Page layout & print: Page Break Preview, print settings, PDF export and printing. +- Static in-cell PNG/JPEG pictures: file insertion, aspect-preserving fit, cell operations and + XLSX rich-value read/write. Clipboard images, floating/cell conversion and `IMAGE()` remain open. - What-if: Goal Seek, scenarios, data tables. Spelling (system word list) and thesaurus. - Draw: pen, highlighter, eraser, ink strokes and Ink to Shape. Task panes: Comments (threaded replies, resolve), Watch Window, Selection, Format Chart. diff --git a/apps/gridcraft-cli/src/main.rs b/apps/gridcraft-cli/src/main.rs index 561162fa5..c97b1d637 100644 --- a/apps/gridcraft-cli/src/main.rs +++ b/apps/gridcraft-cli/src/main.rs @@ -147,6 +147,14 @@ impl Args { fn exec(s: &mut Session, id: &str, params: Value) -> Result { let r = s.execute(id, params).map_err(|e| e.to_string()); s.take_ui_requests(); + if id == "file.open" + && let Ok(value) = &r + && let Some(warnings) = value.get("warnings").and_then(Value::as_array) + { + for warning in warnings.iter().filter_map(Value::as_str) { + eprintln!("Warning: {warning}"); + } + } r } diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs index c06d89074..9a813bebd 100644 --- a/apps/gridcraft-web/src/web.rs +++ b/apps/gridcraft-web/src/web.rs @@ -92,6 +92,7 @@ impl eframe::App for WebShell { fn services(inbox: Inbox, ctx: egui::Context) -> Services { let open_inbox = inbox.clone(); + let picture_ctx = ctx.clone(); Services { open_async: Some(Box::new(move || { let inbox = open_inbox.clone(); @@ -107,6 +108,17 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { ctx.request_repaint(); }); })), + pick_picture_async: Some(Box::new(move |inbox| { + let ctx = picture_ctx.clone(); + wasm_bindgen_futures::spawn_local(async move { + let Some(file) = rfd::AsyncFileDialog::new().add_filter("Pictures (PNG, JPEG)", &["png", "jpg", "jpeg"]).pick_file().await else { + return; + }; + let bytes = file.read().await; + inbox.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push((file.file_name(), bytes)); + ctx.request_repaint(); + }); + })), download: Some(Box::new(|name: &str, bytes: &[u8]| { if let Err(e) = download(name, bytes) { log::error!("download of {name} failed: {e}"); diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 7343932f3..cc7179e59 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -116,6 +116,12 @@ fn services() -> Services { .pick_file() .and_then(|p| p.to_str().map(str::to_string)) })), + pick_picture: Some(Box::new(|| { + rfd::FileDialog::new() + .add_filter("Pictures (PNG, JPEG)", &["png", "jpg", "jpeg"]) + .pick_file() + .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() @@ -220,6 +226,7 @@ fn main() -> eframe::Result<()> { session.new_workbook(); } let mut app = SheetApp::new(session, services()); + app.after_engine(); let control_port = control_port; load_prefs(&mut app); let mut viewport = egui::ViewportBuilder::default() diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index 6ec645ead..71cc79668 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -265,6 +265,7 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec, by_co // Move whole lines (cells with formulas adjusted for relative references). let lanes: Vec = if by_cols { (body.start.row..=body.end.row).collect() } else { (body.start.col..=body.end.col).collect() }; let mut moved: Vec<(CellRef, Option)> = Vec::with_capacity(body.count() as usize); + let mut pictures = Vec::new(); for (dst_i, &src_i) in order.iter().enumerate() { for &k in &lanes { let src = line(src_i, k); @@ -277,6 +278,9 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec, by_co let shifted = gridcraft_formula::adjust::shift_relative(e, dst.row as i64 - src.row as i64, dst.col as i64 - src.col as i64); c.formula = Some(Arc::new(Formula::from_expr(shifted))); } + if let Some(picture) = sh.cell_pictures.get(&src) { + pictures.push((dst, picture.clone())); + } moved.push((dst, cell)); } } @@ -286,12 +290,13 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec, by_co let shm = cx.sheet_mut(sheet)?; for (c, cell) in &moved { match cell { - Some(x) => shm.cells.set(*c, x.clone()), + Some(x) => shm.set_cell(*c, x.clone()), None => { - shm.cells.remove(*c); + shm.remove_cell(*c); } } } + shm.cell_pictures.extend(pictures); for (i, h) in heights.into_iter().enumerate() { let row = body.start.row + i as u32; match h { @@ -600,21 +605,30 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result { removed += 1; } } + let pictures: Vec<_> = keep + .iter() + .enumerate() + .flat_map(|(i, row)| { + (r.start.col..=r.end.col) + .filter_map(move |col| sh.cell_pictures.get(&CellRef::new(*row, col)).map(|p| (CellRef::new(start + i as u32, col), p.clone()))) + }) + .collect(); let rows: Vec>> = keep.iter().map(|row| (r.start.col..=r.end.col).map(|c| sh.cell(CellRef::new(*row, c)).cloned()).collect()).collect(); let shm = cx.sheet_mut(sheet)?; for row in start..=r.end.row { for c in r.start.col..=r.end.col { - shm.cells.remove(CellRef::new(row, c)); + shm.remove_cell(CellRef::new(row, c)); } } for (i, cells) in rows.into_iter().enumerate() { for (j, cell) in cells.into_iter().enumerate() { if let Some(cell) = cell { - shm.cells.set(CellRef::new(start + i as u32, r.start.col + j as u32), cell); + shm.set_cell(CellRef::new(start + i as u32, r.start.col + j as u32), cell); } } } + shm.cell_pictures.extend(pictures); for c in RangeRef::new(CellRef::new(start, r.start.col), r.end).iter() { cx.changed.push((sheet, c)); } @@ -665,9 +679,9 @@ fn text_to_columns(s: &mut Session, p: &Json) -> Result { let cell = super::edit::input_to_cell(&t, sheet, c, &mut cx.wb).unwrap_or(None); let shm = cx.sheet_mut(sheet)?; match cell { - Some(x) => shm.cells.set(c, x), + Some(x) => shm.set_cell(c, x), None => { - shm.cells.remove(c); + shm.remove_cell(c); } } cx.changed.push((sheet, c)); @@ -873,7 +887,7 @@ fn circle_invalid(s: &mut Session, _: &Json) -> Result { let r = sh.used_range().and_then(|u| r.intersection(&u)); let Some(r) = r else { continue }; for c in r.iter().take(100_000) { - let text = sh.cell(c).map(|x| x.input_text()).unwrap_or_default(); + let text = sh.input_text(c); if !text.is_empty() && check_validation(&d.wb, sheet, c, &text).is_some() { bad_cells.push(c.a1()); } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 9b7db40be..917181151 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -213,16 +213,16 @@ fn cell_set(s: &mut Session, p: &Json) -> Result { std::sync::Arc::make_mut(f).array = Some(range); } let sh = cx.sheet_mut(sheet)?; - sh.cells.set(range.start, cell); + sh.set_cell(range.start, cell); cx.touch(sheet, range.start); return Ok(Json::Null); } let cell = input_to_cell(&input, sheet, at, &mut cx.wb)?; let sh = cx.sheet_mut(sheet)?; match cell { - Some(c) => sh.cells.set(at, c), + Some(c) => sh.set_cell(at, c), None => { - sh.cells.remove(at); + sh.remove_cell(at); } } cx.touch(sheet, at); @@ -278,9 +278,9 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result { }; let sh = cx.sheet_mut(sheet)?; match cell { - Some(c) => sh.cells.set(at, c), + Some(c) => sh.set_cell(at, c), None => { - sh.cells.remove(at); + sh.remove_cell(at); } } cx.touch(sheet, at); @@ -312,7 +312,7 @@ fn range_fill(s: &mut Session, p: &Json) -> Result { cell.formula = Some(std::sync::Arc::new(Formula::from_expr(shifted))); } cell.style = cx.wb.sheet(sheet).and_then(|sh| sh.cell(at)).map(|c| c.style).unwrap_or(cell.style); - cx.sheet_mut(sheet)?.cells.set(at, cell); + cx.sheet_mut(sheet)?.set_cell(at, cell); cx.touch(sheet, at); } } @@ -668,9 +668,9 @@ fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result { let cell = input_to_cell(v, sheet, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str())))); let sh = cx.sheet_mut(sheet)?; match cell { - Some(cell) => sh.cells.set(c, cell), + Some(cell) => sh.set_cell(c, cell), None => { - sh.cells.remove(c); + sh.remove_cell(c); } } cx.touch(sheet, c); @@ -783,9 +783,12 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { *style_map.entry(id).or_insert_with(|| wb.styles.intern(src_wb.styles.get(id).clone())) }; let mut moved_formulas = Vec::new(); + let mut moved_pictures = std::collections::BTreeMap::new(); if cut_move { // Remove the source first (contents and formats), and fix references to it. - let taken = cx.sheet_mut(clip.sheet)?.cells.take_range(src); + let source = cx.sheet_mut(clip.sheet)?; + moved_pictures = source.cell_pictures.iter().filter(|(c, _)| src.contains(**c)).map(|(c, p)| (*c, p.clone())).collect(); + let taken = source.take_cells(src); for (c, _) in &taken { cx.touch(clip.sheet, *c); } @@ -804,10 +807,13 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { src_sheet.cell(sc).cloned() }; let src_val = src_sheet.value(sc); - if skip_blanks && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none()) { + let src_picture = if cut_move { moved_pictures.get(&sc) } else { src_sheet.cell_pictures.get(&sc) }.cloned(); + if skip_blanks && src_picture.is_none() && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none()) { continue; } let old = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell(dest)).cloned().unwrap_or_default(); + let old_picture = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell_pictures.get(&dest)).cloned(); + let mut new_picture = old_picture.clone(); let mut new = old.clone(); let shift = |cell: &Cell| -> Option> { let f = cell.formula.as_ref()?; @@ -827,10 +833,12 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { }; new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1())))); new.value = Value::Empty; + new_picture = None; } else { let sc_cell = src_cell.clone().unwrap_or_default(); match what { What::All | What::AllExceptBorders => { + new_picture = src_picture.clone(); new.formula = shift(&sc_cell); new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() }; let st = map_style(sc_cell.style, &mut cx.wb); @@ -842,6 +850,7 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { }; } What::Formulas | What::FormulasAndNumberFormats => { + new_picture = src_picture.clone(); new.formula = shift(&sc_cell); new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() }; if what == What::FormulasAndNumberFormats { @@ -850,6 +859,7 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { } } What::Values | What::ValuesAndNumberFormats => { + new_picture = src_picture.clone(); new.formula = None; new.value = src_val.clone(); if what == What::ValuesAndNumberFormats { @@ -865,6 +875,10 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { && matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas) && new.formula.is_none() { + if old_picture.is_some() || new_picture.is_some() { + new_picture = None; + new.value = Value::Error(gridcraft_core::CellError::Value); + } let a = old.value.clone(); let b = new.value.clone(); if let (Ok(x), Ok(y)) = (a.to_number(), b.to_number()) @@ -902,9 +916,12 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { if let Some(cm) = src_sheet.comments.get(&sc).cloned() { cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm); } - cx.sheet_mut(dest_sheet)?.cells.set(dest, new); + cx.sheet_mut(dest_sheet)?.set_cell(dest, new); } - _ => cx.sheet_mut(dest_sheet)?.cells.set(dest, new), + _ => cx.sheet_mut(dest_sheet)?.set_cell(dest, new), + } + if let Some(picture) = new_picture { + cx.sheet_mut(dest_sheet)?.cell_pictures.insert(dest, picture); } cx.touch(dest_sheet, dest); } @@ -997,12 +1014,12 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result { for (c, mut cell) in cells { match what { "all" => { - sh.cells.remove(c); + sh.remove_cell(c); } "contents" => { cell.value = Value::Empty; cell.formula = None; - sh.cells.set(c, cell); + sh.set_cell(c, cell); } "formats" => { cell.style = StyleId::DEFAULT; @@ -1222,7 +1239,7 @@ fn replace(s: &mut Session, p: &Json) -> Result { let mut n = 0; for si in sheets { let cells: Vec<(CellRef, String)> = - cx.wb.sheet(si).map(|sh| sh.cells.iter().map(|(c, cell)| (c, cell.input_text())).collect()).unwrap_or_default(); + cx.wb.sheet(si).map(|sh| sh.cells.iter().map(|(c, _)| (c, sh.input_text(c))).collect()).unwrap_or_default(); for (c, text) in cells { if !all && c != active { continue; @@ -1240,9 +1257,9 @@ fn replace(s: &mut Session, p: &Json) -> Result { let cell = input_to_cell(&new, si, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(new.as_str())))); let sh = cx.sheet_mut(si)?; match cell { - Some(cell) => sh.cells.set(c, cell), + Some(cell) => sh.set_cell(c, cell), None => { - sh.cells.remove(c); + sh.remove_cell(c); } } cx.touch(si, c); diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 5ebc8b8ce..23610f9e6 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -840,7 +840,7 @@ fn checkbox(s: &mut Session, p: &Json) -> Result { let sh = cx.sheet_mut(si)?; let v = sh.value(c); let value = if matches!(v, Value::Bool(_)) { v } else { Value::Bool(false) }; - sh.cells.set(c, Cell { value, formula: None, style: new }); + sh.set_cell(c, Cell { value, formula: None, style: new }); cx.touch(si, c); } } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 37a58dd63..05485b32d 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -56,6 +56,13 @@ fn open(s: &mut Session, p: &Json) -> Result { s.close_document(0); } let i = s.add_document(DocState::new(wb, path, title)); + if !warnings.is_empty() { + let mut message = warnings.iter().take(5).cloned().collect::>().join("\n\n"); + if warnings.len() > 5 { + message.push_str(&format!("\n\n{} additional import warnings.", warnings.len() - 5)); + } + s.ui_requests.push(crate::UiRequest::Message(message)); + } let d = s.doc()?; Ok( json!({"index": i, "title": d.display_title(), "sheets": d.wb.sheets.iter().map(|s| s.name.clone()).collect::>(), "warnings": warnings}), diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 657fcfab8..4f13bf9c6 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -585,7 +585,7 @@ fn merge(s: &mut Session, p: &Json, how: &str) -> Result { if let Some(mut cell) = sh.cells.get(c).cloned() { cell.value = gridcraft_core::Value::Empty; cell.formula = None; - sh.cells.set(c, cell); + sh.set_cell(c, cell); } touched.push((sheet, c)); } diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index e4b5bc9d1..b7e79cdf8 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -96,7 +96,7 @@ pub fn specs() -> Vec { "Picture", ["Insert", "Illustrations"], None, - "{path? | base64?: \"...\", mime?, at?: \"B2\", width?, height?, alt?}", + "{path? | base64?: \"...\", mime?, at?: \"B2\", width?, height?, alt?, placement?: cell|overCells}", has_doc, insert_picture ), @@ -220,6 +220,13 @@ fn insert_table(s: &mut Session, p: &Json) -> Result { } else { let sh = cx.sheet_mut(sheet)?; sh.cells.shift_rows_in_cols(r.start.col, r.end.col, r.start.row, 1); + sh.cell_pictures = std::mem::take(&mut sh.cell_pictures) + .into_iter() + .filter_map(|(c, v)| { + let dest = if c.col >= r.start.col && c.col <= r.end.col && c.row >= r.start.row { c.offset(1, 0) } else { Some(c) }; + dest.map(|c| (c, v)) + }) + .collect(); for (i, c) in (r.start.col..=r.end.col).enumerate() { sh.set_value(CellRef::new(r.start.row, c), gridcraft_core::Value::text(format!("Column{}", i + 1))); } @@ -304,7 +311,7 @@ fn table_flag(s: &mut Session, p: &Json, which: &str) -> Result { t.range.end.row = t.range.end.row.saturating_sub(1); let (c0, c1) = (t.range.start.col, t.range.end.col); for c in c0..=c1 { - sh.cells.remove(CellRef::new(row, c)); + sh.remove_cell(CellRef::new(row, c)); } } write_totals(sh, ti); @@ -333,12 +340,11 @@ fn write_totals(sh: &mut Sheet, ti: usize) { let c = CellRef::new(row, t.range.start.col + i as u32); if let Some(code) = col.totals.subtotal_code() { let f = format!("=SUBTOTAL({code},{}[{}])", t.name, col.name); - sh.cells - .set(c, Cell { formula: Some(std::sync::Arc::new(Formula::new(&f))), value: gridcraft_core::Value::Empty, style: sh.style_id(c) }); + sh.set_cell(c, Cell { formula: Some(std::sync::Arc::new(Formula::new(&f))), value: gridcraft_core::Value::Empty, style: sh.style_id(c) }); } else if let Some(l) = &col.totals_label { sh.set_value(c, gridcraft_core::Value::text(l.as_str())); } else { - sh.cells.remove(c); + sh.remove_cell(c); } } } @@ -696,14 +702,39 @@ fn insert_sparkline(s: &mut Session, p: &Json) -> Result { } fn insert_picture(s: &mut Session, p: &Json) -> Result { + let placement = str_param(p, "placement").unwrap_or("overCells"); + if !matches!(placement, "cell" | "overCells") { + return Err(bad("insert.picture", "placement must be cell or overCells")); + } let data = if let Some(b) = str_param(p, "base64") { crate::io::base64_decode(b).ok_or_else(|| bad("insert.picture", "invalid base64"))? } else if let Some(path) = str_param(p, "path") { crate::io::read_file(path)? } else { - s.ui_requests.push(crate::UiRequest::Dialog("insertPicture".into(), json!({}))); + s.ui_requests.push(crate::UiRequest::Dialog("insertPicture".into(), json!({"placement": placement}))); return ok(); }; + if placement == "cell" { + let mime = validate_cell_picture(&data)?; + let d = s.doc()?; + let sheet = d.wb.active_sheet; + let sh = d.wb.active().ok_or(EngineError::NoDocument)?; + let at = match str_param(p, "at") { + Some(a) => CellRef::parse(a).ok_or_else(|| bad("insert.picture", "invalid cell address"))?, + None => d.selection.active, + }; + let at = sh.merge_at(at).map(|r| r.start).unwrap_or(at); + let style = sh.style_id(at); + if sh.is_protected() && d.wb.styles.get(style).protection.locked { + return Err(EngineError::Other("The cell you're trying to change is on a protected sheet.".into())); + } + let picture = CellPicture { data, mime: mime.into(), alt: str_param(p, "alt").unwrap_or("").into() }; + return edit(s, |cx| { + cx.sheet_mut(sheet)?.set_picture(at, std::sync::Arc::new(picture)); + cx.touch(sheet, at); + Ok(json!({"cell": at.a1(), "placement": "cell"})) + }); + } if data.len() > 64 * 1024 * 1024 { return Err(bad("insert.picture", "image larger than 64 MB")); } @@ -735,6 +766,33 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result { }) } +/// Validate before mutating the workbook; header dimensions alone do not prove the pixels decode. +fn validate_cell_picture(data: &[u8]) -> Result<&'static str> { + if data.len() > 16 * 1024 * 1024 { + return Err(bad("insert.picture", "cell pictures must be at most 16 MiB")); + } + let format = image::guess_format(data).map_err(|_| bad("insert.picture", "choose a valid PNG or JPEG picture"))?; + let mime = match format { + image::ImageFormat::Png => "image/png", + image::ImageFormat::Jpeg => "image/jpeg", + _ => return Err(bad("insert.picture", "cell pictures support PNG and JPEG")), + }; + let (width, height) = image::ImageReader::with_format(std::io::Cursor::new(data), format) + .into_dimensions() + .map_err(|_| bad("insert.picture", "invalid image dimensions"))?; + if width == 0 || height == 0 || width > 8192 || height > 8192 || u64::from(width) * u64::from(height) > 16_000_000 { + return Err(bad("insert.picture", "cell pictures are limited to 8192 pixels per side and 16 million pixels")); + } + let mut reader = image::ImageReader::with_format(std::io::Cursor::new(data), format); + let mut limits = image::Limits::default(); + limits.max_image_width = Some(8192); + limits.max_image_height = Some(8192); + limits.max_alloc = Some(64 * 1024 * 1024); + reader.limits(limits); + reader.decode().map_err(|_| bad("insert.picture", "picture cannot be decoded within the 64 MiB limit"))?; + Ok(mime) +} + fn insert_shape(s: &mut Session, p: &Json) -> Result { let kind = match str_param(p, "kind").unwrap_or("rectangle") { "roundedRectangle" => ShapeKind::RoundedRectangle, @@ -846,7 +904,7 @@ fn insert_link(s: &mut Session, p: &Json) -> Result { }); let sh = cx.sheet_mut(sheet)?; if let Some(t) = text.clone().or_else(|| sh.value(at).is_empty().then(|| target.clone())) { - sh.cells.set(at, Cell { value: gridcraft_core::Value::text(t.as_str()), formula: None, style: link_style }); + sh.set_cell(at, Cell { value: gridcraft_core::Value::text(t.as_str()), formula: None, style: link_style }); } else { sh.set_style(at, link_style); } diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index 989f58785..de670e9b8 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -125,10 +125,11 @@ fn cell_get(s: &mut Session, p: &Json) -> Result { Ok(json!({ "cell": c.a1(), "value": value_json(&sh.value(c)), - "type": match sh.value(c) { Value::Empty => "empty", Value::Number(_) => "number", Value::Text(_) => "text", Value::Bool(_) => "boolean", Value::Error(_) => "error", Value::Array(_) => "array" }, + "type": if sh.cell_pictures.contains_key(&c) { "picture" } else { match sh.value(c) { Value::Empty => "empty", Value::Number(_) => "number", Value::Text(_) => "text", Value::Bool(_) => "boolean", Value::Error(_) => "error", Value::Array(_) => "array" } }, "formula": cell.and_then(|x| x.formula.as_ref()).map(|f| format!("={}", f.text)), "text": crate::display::cell_text(&d.wb, sh, c), - "input": cell.map(|x| x.input_text()).unwrap_or_default(), + "input": sh.input_text(c), + "picture": sh.cell_pictures.get(&c).map(|picture| json!({"mime": picture.mime, "alt": picture.alt, "bytes": picture.data.len()})), "spilledFrom": sh.spill_ranges.iter().find(|(a, r)| **a != c && r.contains(c)).map(|(a, _)| a.a1()), "spill": sh.spill_ranges.get(&c).map(|r| r.a1()), "style": style, diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 09ea606e0..2b3594368 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -847,11 +847,41 @@ fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Option 16 * 1024 * 1024 { + return; + } + let Some((iw, ih)) = crate::io::image_size(data) else { return }; + if iw == 0 || ih == 0 || iw > 8192 || ih > 8192 || u64::from(iw) * u64::from(ih) > 16_000_000 { + return; + } + let Ok(mut reader) = image::ImageReader::new(std::io::Cursor::new(data)).with_guessed_format() else { return }; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(8192); + limits.max_image_height = Some(8192); + limits.max_alloc = Some(64 * 1024 * 1024); + reader.limits(limits); + let Ok(image) = reader.decode() else { return }; + let image = + image.thumbnail(((w / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32, ((h / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32).to_rgba8(); + let fit = (w / iw as f32).min(h / ih as f32); + let (pw, ph) = (iw as f32 * fit, ih as f32 * fit); + pg.image_rgba(x + pad + (w - pw) / 2.0, y + pad + (h - ph) / 2.0, pw, ph, image.as_raw(), image.width(), image.height()); +} + /// Is a cell free for text to overflow into? fn empty_for_overflow(sh: &Sheet, c: CellRef, merges: &[RangeRef]) -> bool { sh.value_ref(c).is_none_or(|v| v.is_empty()) && !merges.iter().any(|m| m.contains(c)) diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 2d3be7f0a..36d1beaeb 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -137,6 +137,8 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert: None => sh.autofilter = None, } } + sh.cell_pictures = + std::mem::take(&mut sh.cell_pictures).into_iter().filter_map(|(c, v)| map_cell(c).filter(CellRef::is_valid).map(|c| (c, v))).collect(); sh.comments = std::mem::take(&mut sh.comments).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect(); sh.hyperlinks = std::mem::take(&mut sh.hyperlinks).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect(); for ch in sh.charts.iter_mut() { @@ -247,7 +249,7 @@ fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result { Axis::Rows => RangeRef::rows(at, (at + count - 1).min(MAX_ROWS - 1)), Axis::Cols => RangeRef::cols(at, (at + count - 1).min(MAX_COLS - 1)), }; - sh.cells.take_range(block); + sh.take_cells(block); match axis { Axis::Rows => sh.cells.shift_rows(at + count, -(count as i64)), Axis::Cols => sh.cells.shift_cols(at + count, -(count as i64)), @@ -305,7 +307,7 @@ fn delete_cells(s: &mut Session, p: &Json) -> Result { let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default(); { let sh = cx.sheet_mut(sheet)?; - sh.cells.take_range(r); + sh.take_cells(r); if left { sh.cells.shift_cols_in_rows(r.start.row, r.end.row, r.end.col + 1, -(r.width() as i64)); } else { @@ -648,6 +650,8 @@ fn shift_cell_features(sh: &mut Sheet, e: &Edit) { None => sh.autofilter = None, } } + sh.cell_pictures = + std::mem::take(&mut sh.cell_pictures).into_iter().filter_map(|(c, v)| map_cell(c).filter(CellRef::is_valid).map(|c| (c, v))).collect(); sh.comments = std::mem::take(&mut sh.comments).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect(); sh.hyperlinks = std::mem::take(&mut sh.hyperlinks).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect(); for sp in sh.sparklines.iter_mut() { diff --git a/crates/engine/src/cmd/whatif.rs b/crates/engine/src/cmd/whatif.rs index c0a61df64..22702fba4 100644 --- a/crates/engine/src/cmd/whatif.rs +++ b/crates/engine/src/cmd/whatif.rs @@ -201,7 +201,7 @@ impl Trial { for (si, c, v) in sets { if let Some(sh) = wb.sheet_mut(*si) { let style = sh.style_id(*c); - sh.cells.set(*c, Cell { value: v.clone(), formula: None, style }); + sh.set_cell(*c, Cell { value: v.clone(), formula: None, style }); keys.push((*si, *c)); } } @@ -330,7 +330,7 @@ fn goal_seek(s: &mut Session, p: &Json) -> Result { edit(s, |cx| { let sh = cx.sheet_mut(csi)?; let style = sh.style_id(chg); - sh.cells.set(chg, Cell { value: Value::number(r.x), formula: None, style }); + sh.set_cell(chg, Cell { value: Value::number(r.x), formula: None, style }); cx.touch(csi, chg); Ok(()) })?; @@ -492,9 +492,9 @@ fn scenario_show(s: &mut Session, p: &Json) -> Result { let cell = super::edit::input_to_cell(&input, si, *c, &mut cx.wb)?; let sh = cx.sheet_mut(si)?; match cell { - Some(cell) => sh.cells.set(*c, cell), + Some(cell) => sh.set_cell(*c, cell), None => { - sh.cells.remove(*c); + sh.remove_cell(*c); } } cx.touch(si, *c); @@ -571,7 +571,7 @@ fn scenario_summary(s: &mut Session, p: &Json) -> Result { let bold = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.font.bold = true); let mut sheet = Sheet::new(name.clone()); let put = |sheet: &mut Sheet, row: u32, col: u32, v: Value, style: StyleId| { - sheet.cells.set(CellRef::new(row, col), Cell { value: v, formula: None, style }); + sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style }); }; put(&mut sheet, 1, 1, Value::text("Scenario Summary"), bold); put(&mut sheet, 2, 2, Value::text("Current Values:"), bold); @@ -694,7 +694,7 @@ fn data_table(s: &mut Session, p: &Json) -> Result { for (c, v) in out { let sh = cx.sheet_mut(si)?; let style = sh.style_id(c); - sh.cells.set(c, Cell { value: v, formula: None, style }); + sh.set_cell(c, Cell { value: v, formula: None, style }); cx.touch(si, c); } Ok(()) @@ -890,10 +890,10 @@ fn advanced_filter(s: &mut Session, p: &Json) -> Result { let shm = cx.sheet_mut(dsi)?; let old: Vec = shm.cells.iter_range(extract).map(|(c, _)| c).collect(); for c in &old { - shm.cells.remove(*c); + shm.remove_cell(*c); } for (c, cell) in cells { - shm.cells.set(c, cell); + shm.set_cell(c, cell); } for c in old { cx.touch(dsi, c); @@ -1107,9 +1107,9 @@ fn consolidate(s: &mut Session, p: &Json) -> Result { let sh = cx.sheet_mut(dsi)?; let style = sh.style_id(c); if v.is_empty() && style == StyleId::DEFAULT { - sh.cells.remove(c); + sh.remove_cell(c); } else { - sh.cells.set(c, Cell { value: v, formula: None, style }); + sh.set_cell(c, Cell { value: v, formula: None, style }); } cx.touch(dsi, c); } diff --git a/crates/engine/src/fill.rs b/crates/engine/src/fill.rs index cf9e08f75..49ac14424 100644 --- a/crates/engine/src/fill.rs +++ b/crates/engine/src/fill.rs @@ -45,6 +45,7 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F let lanes: Vec = if vertical { (src.start.col..=src.end.col).collect() } else { (src.start.row..=src.end.row).collect() }; let src_len = if vertical { src.height() } else { src.width() }; let mut writes: Vec<(CellRef, Option)> = Vec::new(); + let mut pictures = Vec::new(); for lane in lanes { let src_cells: Vec<(CellRef, Option)> = (0..src_len) .map(|i| { @@ -97,18 +98,23 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F cell.style = style; } } + if let Some(picture) = sh.cell_pictures.get(&sc) { + pictures.push((*t, picture.clone())); + } writes.push((*t, if cell.is_blank() { None } else { Some(cell) })); } } let sh = cx.sheet_mut(sheet)?; for (c, cell) in &writes { match cell { - Some(x) => sh.cells.set(*c, x.clone()), + Some(x) if mode == FillMode::Formats => sh.cells.set(*c, x.clone()), + Some(x) => sh.set_cell(*c, x.clone()), None => { - sh.cells.remove(*c); + sh.remove_cell(*c); } } } + sh.cell_pictures.extend(pictures); for (c, _) in writes { cx.touch(sheet, c); } @@ -339,7 +345,7 @@ pub fn series(cx: &mut Ctx, sheet: usize, r: RangeRef, rows: bool, kind: &str, s } let sh = cx.sheet_mut(sheet)?; for (c, cell) in &writes { - sh.cells.set(*c, cell.clone()); + sh.set_cell(*c, cell.clone()); } for (c, _) in writes { cx.touch(sheet, c); @@ -393,7 +399,7 @@ pub fn flash_fill(cx: &mut Ctx, sheet: usize, at: CellRef) -> Result { } let sh = cx.sheet_mut(sheet)?; for (c, out) in &writes { - sh.cells.set(*c, Cell { value: Value::text(out.as_str()), formula: None, style }); + sh.set_cell(*c, Cell { value: Value::text(out.as_str()), formula: None, style }); } for (c, _) in writes { cx.touch(sheet, c); diff --git a/crates/engine/src/pivot.rs b/crates/engine/src/pivot.rs index 02a77f246..467ad78f2 100644 --- a/crates/engine/src/pivot.rs +++ b/crates/engine/src/pivot.rs @@ -1184,11 +1184,11 @@ pub fn refresh(wb: &mut Workbook, sheet: usize, idx: usize, replace: bool) -> Re } let shm = wb.sheet_mut(sheet).ok_or("There's no such sheet.")?; if let Some(old) = pt.last_range { - shm.cells.take_range(old); + shm.take_cells(old); } - shm.cells.take_range(area); + shm.take_cells(area); for (c, cell) in cells { - shm.cells.set(c, cell); + shm.set_cell(c, cell); } for (col, wpt) in widths { if wpt > shm.col_width(col) && !shm.is_col_hidden(col) { @@ -1205,7 +1205,7 @@ pub fn refresh(wb: &mut Workbook, sheet: usize, idx: usize, replace: bool) -> Re pub fn clear_output(wb: &mut Workbook, sheet: usize, idx: usize) { let Some(shm) = wb.sheet_mut(sheet) else { return }; let Some(r) = shm.pivots.get(idx).and_then(|p| p.last_range) else { return }; - shm.cells.take_range(r); + shm.take_cells(r); if let Some(p) = shm.pivots.get_mut(idx) { p.last_range = None; } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7cf6ec55b..0926f9f4e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -619,3 +619,183 @@ fn ink_strokes_and_ink_to_shape() { assert_eq!(s.execute("draw.inkToShape", json!({})).unwrap()["kind"], "Line"); assert!(s.execute("draw.stroke", json!({"points": [[1.0, 1.0]]})).is_err()); } + +fn original_cell_picture() -> Vec { + let image = image::RgbaImage::from_fn(4, 2, |x, _| if x < 2 { image::Rgba([240, 60, 40, 255]) } else { image::Rgba([20, 100, 240, 128]) }); + let mut png = Vec::new(); + image::DynamicImage::ImageRgba8(image).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + png +} + +fn insert_cell_picture(s: &mut Session, at: &str, alt: &str) { + s.execute("insert.picture", json!({"placement": "cell", "at": at, "base64": crate::io::base64_encode(&original_cell_picture()), "alt": alt})) + .unwrap(); +} + +fn cell_picture(s: &Session, at: &str) -> Option> { + s.doc().unwrap().wb.active().unwrap().cell_pictures.get(&CellRef::parse(at).unwrap()).cloned() +} + +#[test] +fn cell_picture_insert_validates_content_preserves_style_and_replaces_with_undo() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "B2", "input": "old"})).unwrap(); + s.execute("home.bold", json!({"range": "B2"})).unwrap(); + for bytes in [b"not an image".as_slice(), b"GIF89a", &original_cell_picture()[..24]] { + assert!(s.execute("insert.picture", json!({"placement": "cell", "at": "B2", "base64": crate::io::base64_encode(bytes)})).is_err()); + assert_eq!(v(&s, "B2"), Value::from("old")); + } + insert_cell_picture(&mut s, "B2", "Original picture"); + assert_eq!(v(&s, "B2"), Value::Error(gridcraft_core::CellError::Value)); + let state = s.execute("cell.get", json!({"cell": "B2"})).unwrap(); + assert_eq!(state["type"], "picture"); + assert_eq!(state["text"], "#VALUE!"); + assert_eq!(state["input"], ""); + assert_eq!(state["picture"]["alt"], "Original picture"); + assert!(state["picture"].get("data").is_none()); + assert_eq!(state["style"]["font"]["bold"], true); + assert!(s.doc().unwrap().wb.active().unwrap().images.is_empty()); + s.execute("cell.set", json!({"cell": "B2", "input": "=2+3"})).unwrap(); + assert!(cell_picture(&s, "B2").is_none()); + assert_eq!(v(&s, "B2"), Value::Number(5.0)); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(cell_picture(&s, "B2").unwrap().alt, "Original picture"); + s.execute("range.setValues", json!({"range": "B2", "values": [[7]]})).unwrap(); + assert!(cell_picture(&s, "B2").is_none()); + let mut jpeg = Vec::new(); + image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(2, 1, image::Rgb([60, 80, 100]))) + .write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg) + .unwrap(); + s.execute("insert.picture", json!({"placement": "cell", "at": "D2", "base64": crate::io::base64_encode(&jpeg), "mime": "wrong/client-mime"})) + .unwrap(); + assert_eq!(cell_picture(&s, "D2").unwrap().mime, "image/jpeg"); +} + +#[test] +fn cell_picture_clipboard_clear_and_structural_edits_keep_complete_payload() { + let mut s = s(); + insert_cell_picture(&mut s, "B2", "moves with the row"); + let original = cell_picture(&s, "B2").unwrap(); + s.execute("edit.copy", json!({"range": "B2"})).unwrap(); + s.execute("edit.pasteSpecial", json!({"what": "values", "at": "D2"})).unwrap(); + assert!(std::sync::Arc::ptr_eq(&original, &cell_picture(&s, "D2").unwrap())); + s.execute("home.bold", json!({"range": "C1"})).unwrap(); + s.execute("edit.copy", json!({"range": "C1"})).unwrap(); + s.execute("edit.pasteSpecial", json!({"what": "formats", "at": "D2"})).unwrap(); + assert_eq!(cell_picture(&s, "D2").unwrap().alt, original.alt); + s.execute("edit.clearFormats", json!({"range": "D2"})).unwrap(); + assert!(cell_picture(&s, "D2").is_some()); + s.execute("edit.clearContents", json!({"range": "D2"})).unwrap(); + assert!(cell_picture(&s, "D2").is_none()); + s.execute("cell.set", json!({"cell": "C1", "input": "3"})).unwrap(); + s.execute("edit.copy", json!({"range": "B2"})).unwrap(); + s.execute("edit.pasteSpecial", json!({"what": "values", "at": "C1", "operation": "add"})).unwrap(); + assert!(cell_picture(&s, "C1").is_none()); + assert_eq!(v(&s, "C1"), Value::Error(gridcraft_core::CellError::Value)); + s.execute("home.insertRows", json!({"rows": "2:2"})).unwrap(); + s.execute("home.insertColumns", json!({"cols": "B:B"})).unwrap(); + assert_eq!(cell_picture(&s, "C3").unwrap().alt, original.alt); + s.execute("edit.cut", json!({"range": "C3"})).unwrap(); + s.execute("home.insertSheet", json!({"name": "Destination"})).unwrap(); + s.execute("edit.paste", json!({"at": "E4"})).unwrap(); + assert_eq!(cell_picture(&s, "E4").unwrap().alt, original.alt); + assert!(s.doc().unwrap().wb.sheet(0).unwrap().cell(CellRef::parse("C3").unwrap()).is_none()); + s.execute("home.deleteColumns", json!({"cols": "E:E"})).unwrap(); + assert!(cell_picture(&s, "E4").is_none()); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(cell_picture(&s, "E4").unwrap().data, original.data); +} + +#[test] +fn cell_picture_follows_sort_filter_and_blocks_formula_spills() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Rank", "Picture"], [2], [1]]})).unwrap(); + insert_cell_picture(&mut s, "B2", "rank two"); + insert_cell_picture(&mut s, "B3", "rank one"); + s.execute("data.sort", json!({"range": "A1:B3", "header": true, "keys": [{"column": "A", "order": "asc"}]})).unwrap(); + assert_eq!(cell_picture(&s, "B2").unwrap().alt, "rank one"); + assert_eq!(cell_picture(&s, "B3").unwrap().alt, "rank two"); + s.execute("selection.set", json!({"range": "A1:B3"})).unwrap(); + s.execute("data.filter", json!({})).unwrap(); + s.execute("data.filterBy", json!({"column": "A", "values": ["1"]})).unwrap(); + assert!(s.doc().unwrap().wb.active().unwrap().is_row_hidden(2)); + assert!(cell_picture(&s, "B3").is_some()); + insert_cell_picture(&mut s, "D2", "spill obstruction"); + s.execute("cell.set", json!({"cell": "D1", "input": "=SEQUENCE(2)"})).unwrap(); + assert_eq!(v(&s, "D1"), Value::Error(gridcraft_core::CellError::Spill)); + s.execute("edit.clearContents", json!({"range": "D2"})).unwrap(); + assert_eq!(v(&s, "D1"), Value::Number(1.0)); + assert_eq!(v(&s, "D2"), Value::Number(2.0)); +} + +#[test] +fn cell_picture_json_save_retains_content_and_pdf_draws_without_error_text() { + let mut s = s(); + insert_cell_picture(&mut s, "B2", "original pixels"); + let saved = s.execute("file.saveBytes", json!({"format": "json"})).unwrap(); + s.execute("file.open", json!({"name": "pictures.json", "base64": saved["base64"]})).unwrap(); + assert_eq!(cell_picture(&s, "B2").unwrap().data, original_cell_picture()); + let pdf = s.execute("file.exportPdf", json!({"range": "A1:C3"})).unwrap(); + let data = crate::io::base64_decode(pdf["base64"].as_str().unwrap()).unwrap(); + let pdf_text = String::from_utf8_lossy(&data); + assert!(pdf_text.contains("/Subtype /Image")); + assert!(!pdf_text.contains("(#VALUE!) Tj")); + s.execute("home.hideRows", json!({"rows": "2:2"})).unwrap(); + let pdf = s.execute("file.exportPdf", json!({"range": "A1:C3"})).unwrap(); + let data = crate::io::base64_decode(pdf["base64"].as_str().unwrap()).unwrap(); + assert!(!String::from_utf8_lossy(&data).contains("/Subtype /Image")); +} + +#[test] +fn cell_picture_side_map_follows_fill_shift_merge_and_undo() { + let mut s = s(); + insert_cell_picture(&mut s, "B2", "shared pixels"); + let original = cell_picture(&s, "B2").unwrap(); + s.execute("edit.fillDown", json!({"range": "B2:B4"})).unwrap(); + for at in ["B2", "B3", "B4"] { + assert!(std::sync::Arc::ptr_eq(&original, &cell_picture(&s, at).unwrap())); + } + s.execute("edit.autoFill", json!({"source": "A2", "target": "A2:B2", "mode": "formats"})).unwrap(); + assert!(cell_picture(&s, "B2").is_some()); + s.execute("edit.autoFill", json!({"source": "A3", "target": "A3:B3", "mode": "copy"})).unwrap(); + assert!(cell_picture(&s, "B3").is_none()); + s.execute("home.insertCells", json!({"range": "B2", "shift": "down"})).unwrap(); + assert!(cell_picture(&s, "B2").is_none()); + assert!(cell_picture(&s, "B3").is_some()); + assert!(cell_picture(&s, "B5").is_some()); + s.execute("home.deleteCells", json!({"range": "B3", "shift": "up"})).unwrap(); + assert!(cell_picture(&s, "B3").is_none()); + assert!(cell_picture(&s, "B4").is_some()); + s.execute("home.mergeCells", json!({"range": "A4:B4"})).unwrap(); + assert!(cell_picture(&s, "B4").is_none()); + s.execute("edit.undo", json!({})).unwrap(); + assert!(cell_picture(&s, "B4").is_some()); + s.execute("edit.clearAll", json!({"range": "A1:C5"})).unwrap(); + assert!(s.doc().unwrap().wb.active().unwrap().cell_pictures.is_empty()); + insert_cell_picture(&mut s, "XFD1048576", "grid edge"); + assert!(s.execute("home.insertRows", json!({"rows": "1048576:1048576"})).is_err()); + assert_eq!(cell_picture(&s, "XFD1048576").unwrap().alt, "grid edge"); +} + +#[test] +fn cell_picture_cut_uses_live_source_after_edit() { + for clear in [false, true] { + let mut s = s(); + insert_cell_picture(&mut s, "B2", "original"); + s.execute("edit.cut", json!({"range": "B2"})).unwrap(); + if clear { + s.execute("edit.clearContents", json!({"range": "B2"})).unwrap(); + } else { + insert_cell_picture(&mut s, "B2", "replacement"); + } + s.execute("edit.paste", json!({"at": "D2"})).unwrap(); + assert!(cell_picture(&s, "B2").is_none()); + if clear { + assert!(cell_picture(&s, "D2").is_none()); + assert_eq!(v(&s, "D2"), Value::Empty); + } else { + assert_eq!(cell_picture(&s, "D2").unwrap().alt, "replacement"); + assert_eq!(v(&s, "D2"), Value::Error(gridcraft_core::CellError::Value)); + } + } +} diff --git a/crates/model/src/cell.rs b/crates/model/src/cell.rs index cd5a6353d..951583b5e 100644 --- a/crates/model/src/cell.rs +++ b/crates/model/src/cell.rs @@ -42,6 +42,16 @@ impl Formula { } } +/// A static picture stored as cell content, independent of floating drawing anchors. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CellPicture { + /// Encoded PNG/JPEG bytes, base64 in JSON. Sheet entries share the payload through Arc. + #[serde(with = "crate::b64")] + pub data: Vec, + pub mime: String, + pub alt: String, +} + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct Cell { /// Constant value, or the cached result of the formula. diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index ba0f32c43..9500303f7 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -12,7 +12,7 @@ pub mod store; pub mod style; pub mod workbook; -pub use cell::{Cell, Formula}; +pub use cell::{Cell, CellPicture, Formula}; pub use features::*; pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, Visibility}; pub use store::CellStore; diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index 4b4200d39..1f724bce6 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -1,11 +1,12 @@ //! Worksheets. use std::collections::BTreeMap; +use std::sync::Arc; use gridcraft_core::{CellRef, RangeRef, Value}; use serde::{Deserialize, Serialize}; -use crate::cell::Cell; +use crate::cell::{Cell, CellPicture}; use crate::features::*; use crate::store::CellStore; use crate::style::{Color, StyleId}; @@ -22,6 +23,9 @@ pub enum Visibility { pub struct Sheet { pub name: String, pub cells: CellStore, + /// Static pictures are sparse rich content. Ordinary cells carry no picture pointer. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty", with = "picture_map")] + pub cell_pictures: BTreeMap>, /// Values spilled by dynamic-array formulas (maintained by calc), keyed by cell. #[serde(skip)] pub spill: BTreeMap, @@ -73,6 +77,7 @@ impl Sheet { Sheet { name: name.into(), cells: CellStore::new(), + cell_pictures: BTreeMap::new(), spill: BTreeMap::new(), spill_ranges: BTreeMap::new(), cols: BTreeMap::new(), @@ -134,7 +139,30 @@ impl Sheet { } pub fn set_value(&mut self, c: CellRef, v: Value) { let style = self.style_id(c); - self.cells.set(c, Cell { value: v, formula: None, style }); + self.set_cell(c, Cell { value: v, formula: None, style }); + } + /// Replaces content, removing any picture at this address. Style-only edits use set_style. + pub fn set_cell(&mut self, c: CellRef, cell: Cell) { + self.cell_pictures.remove(&c); + self.cells.set(c, cell); + } + pub fn remove_cell(&mut self, c: CellRef) -> Option { + self.cell_pictures.remove(&c); + self.cells.remove(c) + } + /// Removes content in a range; returns scalar cells, with pictures removed from the side map. + pub fn take_cells(&mut self, range: RangeRef) -> Vec<(CellRef, Cell)> { + self.cell_pictures.retain(|c, _| !range.contains(*c)); + self.cells.take_range(range) + } + /// Pictures retain a scalar fallback for calculation and unsupported consumers. + pub fn set_picture(&mut self, c: CellRef, picture: Arc) { + let style = self.style_id(c); + self.cells.set(c, Cell { value: Value::Error(gridcraft_core::CellError::Value), formula: None, style }); + self.cell_pictures.insert(c, picture); + } + pub fn input_text(&self, c: CellRef) -> String { + if self.cell_pictures.contains_key(&c) { String::new() } else { self.cell(c).map(Cell::input_text).unwrap_or_default() } } pub fn set_style(&mut self, c: CellRef, style: StyleId) { let mut cell = self.cells.get(c).cloned().unwrap_or_default(); @@ -339,3 +367,15 @@ mod tests { assert_eq!(s.col_left(2), 128.0); } } + +// CellRef is a struct, so encode the sparse map as entries for JSON as well as other formats. +mod picture_map { + use super::*; + use serde::{Deserializer, Serializer}; + pub fn serialize(map: &BTreeMap>, serializer: S) -> Result { + map.iter().collect::>().serialize(serializer) + } + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result>, D::Error> { + Ok(Vec::<(CellRef, Arc)>::deserialize(deserializer)?.into_iter().collect()) + } +} diff --git a/crates/ui-egui/src/cell_pictures.rs b/crates/ui-egui/src/cell_pictures.rs new file mode 100644 index 000000000..461014022 --- /dev/null +++ b/crates/ui-egui/src/cell_pictures.rs @@ -0,0 +1,183 @@ +//! Cell pictures use the current cell geometry, independent of floating objects. + +use std::collections::HashMap; +use std::io::Cursor; +use std::sync::{Arc, Mutex, Weak}; + +use egui::{Color32, Painter, Rect, TextureHandle, pos2, vec2}; +use gridcraft_engine::model::CellPicture; + +const MAX_ENCODED: usize = 16 * 1024 * 1024; +const MAX_PIXELS: u64 = 16_000_000; +const MAX_CACHE_BYTES: usize = 64 * 1024 * 1024; +// The byte budget controls texture memory; sparse entries have a separate modest bound. +const MAX_CACHE_ITEMS: usize = 4096; + +#[derive(Default)] +struct Cache { + entries: HashMap, + bytes: usize, +} + +struct Entry { + // Holding a Weak prevents this allocation address from being reused. The + // immutable Arc is the content identity: copy/undo share it; replacement + // creates another allocation. No source bytes are cloned or hashed per frame. + source: Weak, + texture: Option, + bytes: usize, + used: u64, +} + +impl Cache { + fn texture(&mut self, ctx: &egui::Context, picture: &Arc) -> Option { + let frame = ctx.cumulative_frame_nr(); + let key = Arc::as_ptr(picture) as usize; + if let Some(entry) = self.entries.get_mut(&key) { + entry.used = frame; + return entry.texture.clone(); + } + // Prune discarded workbooks/payloads before allocating another texture. + self.entries.retain(|_, entry| { + if entry.source.strong_count() == 0 { + self.bytes = self.bytes.saturating_sub(entry.bytes); + false + } else { + true + } + }); + // Read only the header before admission. Never decode an image just to evict it + // again while painting a larger visible set on the next frame. + let source = dimensions(picture); + let estimate = source.map_or(0, |(_, (w, h))| w as usize * h as usize * 4); + while self.entries.len() >= MAX_CACHE_ITEMS || self.bytes.saturating_add(estimate) > MAX_CACHE_BYTES { + let oldest = self.entries.iter().filter(|(_, e)| e.used.saturating_add(1) < frame).min_by_key(|(_, e)| e.used).map(|(key, _)| *key); + let Some(oldest) = oldest else { + // Keep visible images stable under pressure; show the placeholder for + // excess content instead of re-decoding it each frame. + return None; + }; + if let Some(entry) = self.entries.remove(&oldest) { + self.bytes = self.bytes.saturating_sub(entry.bytes); + } + } + let decoded = source.and_then(|(format, _)| decode_format(picture, format)); + let bytes = decoded.as_ref().map_or(0, |image| image.pixels.len() * 4); + let texture = decoded.map(|image| ctx.load_texture("cell picture", image, egui::TextureOptions::LINEAR)); + self.bytes += bytes; + self.entries.insert(key, Entry { source: Arc::downgrade(picture), texture: texture.clone(), bytes, used: frame }); + texture + } +} + +fn dimensions(picture: &CellPicture) -> Option<(image::ImageFormat, (u32, u32))> { + if picture.data.len() > MAX_ENCODED { + return None; + } + let format = image::guess_format(&picture.data).ok()?; + if !matches!(format, image::ImageFormat::Png | image::ImageFormat::Jpeg) { + return None; + } + let dimensions = image::ImageReader::with_format(Cursor::new(&picture.data), format).into_dimensions().ok()?; + if dimensions.0 == 0 + || dimensions.1 == 0 + || dimensions.0 > 8192 + || dimensions.1 > 8192 + || u64::from(dimensions.0) * u64::from(dimensions.1) > MAX_PIXELS + { + return None; + } + Some((format, dimensions)) +} + +#[cfg(test)] +fn decode(picture: &CellPicture) -> Option { + let (format, _) = dimensions(picture)?; + decode_format(picture, format) +} + +fn decode_format(picture: &CellPicture, format: image::ImageFormat) -> Option { + let mut reader = image::ImageReader::with_format(Cursor::new(&picture.data), format); + let mut limits = image::Limits::default(); + limits.max_image_width = Some(8192); + limits.max_image_height = Some(8192); + limits.max_alloc = Some(64 * 1024 * 1024); + reader.limits(limits); + let rgba = reader.decode().ok()?.into_rgba8(); + Some(egui::ColorImage::from_rgba_unmultiplied([rgba.width() as usize, rgba.height() as usize], rgba.as_raw())) +} + +fn fitted_rect(cell: Rect, size: [usize; 2]) -> Option { + if cell.width() <= 0.0 || cell.height() <= 0.0 || size.contains(&0) { + return None; + } + let source = vec2(size[0] as f32, size[1] as f32); + let scale = (cell.width() / source.x).min(cell.height() / source.y); + Some(Rect::from_center_size(cell.center(), source * scale)) +} + +pub(crate) fn paint(painter: &Painter, cell: Rect, picture: &Arc) { + if cell.width() <= 0.0 || cell.height() <= 0.0 || !cell.intersects(painter.clip_rect()) { + return; + } + // Context-local handles must never leak between offscreen, native or browser + // egui contexts, even when a workbook shares its Arc payload with another view. + let cache = painter.ctx().data_mut(|data| data.get_temp_mut_or_default::>>(egui::Id::new("gridcraft.cell_pictures")).clone()); + let texture = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner).texture(painter.ctx(), picture); + let painter = painter.with_clip_rect(cell.intersect(painter.clip_rect())); + if let Some(texture) = texture { + if let Some(rect) = fitted_rect(cell, texture.size()) { + painter.image(texture.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + } else { + crate::icons::paint(&painter, cell.shrink(2.0), crate::icons::Icon::Picture, Color32::from_gray(130)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn picture(color: [u8; 4]) -> Result, image::ImageError> { + let image = image::RgbaImage::from_pixel(4, 2, image::Rgba(color)); + let mut bytes = Cursor::new(Vec::new()); + image.write_to(&mut bytes, image::ImageFormat::Png)?; + Ok(Arc::new(CellPicture { data: bytes.into_inner(), mime: "image/png".into(), alt: "Test picture".into() })) + } + + #[test] + fn aspect_fit_and_bounded_content_cache() -> Result<(), Box> { + let cell = Rect::from_min_size(pos2(10.0, 20.0), vec2(100.0, 100.0)); + assert_eq!(fitted_rect(cell, [200, 100]), Some(Rect::from_min_size(pos2(10.0, 45.0), vec2(100.0, 50.0)))); + assert_eq!(fitted_rect(cell, [100, 200]), Some(Rect::from_min_size(pos2(35.0, 20.0), vec2(50.0, 100.0)))); + assert!(fitted_rect(Rect::NOTHING, [200, 100]).is_none()); + let ctx = egui::Context::default(); + let mut cache = Cache::default(); + let red = picture([255, 0, 0, 255])?; + let blue = picture([0, 0, 255, 255])?; + // Distinct content with identical dimensions must receive distinct handles. + let red_id = cache.texture(&ctx, &red).ok_or("missing red texture")?.id(); + assert_eq!(cache.texture(&ctx, &red).ok_or("missing red texture")?.id(), red_id); + assert_ne!(cache.texture(&ctx, &blue).ok_or("missing blue texture")?.id(), red_id); + let payloads: Vec<_> = (0..133).map(|_| picture([0, 0, 255, 255])).collect::>()?; + for payload in &payloads { + cache.texture(&ctx, payload); + } + let ids: Vec<_> = + payloads.iter().map(|p| cache.texture(&ctx, p).ok_or("missing visible texture").map(|t| t.id())).collect::>()?; + for _ in 0..3 { + let mut output = ctx.run_ui(Default::default(), |_| { + for (payload, id) in payloads.iter().zip(&ids) { + assert_eq!(cache.texture(&ctx, payload).map(|t| t.id()), Some(*id)); + } + }); + // This test observes handles without a renderer consuming their upload deltas. + output.textures_delta.clear(); + } + assert!(cache.entries.len() <= MAX_CACHE_ITEMS); + assert!(cache.bytes <= MAX_CACHE_BYTES); + assert!(decode(&CellPicture { data: vec![], mime: "image/png".into(), alt: String::new() }).is_none()); + assert_eq!(decode(&red).ok_or("decode failed")?.pixels.first(), Some(&Color32::RED)); + Ok(()) + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7753a48b1..6938faee0 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -31,6 +31,8 @@ pub struct Dialog { pub search: String, pub list_index: usize, pub result: Option, + picture_inbox: Option, + picture_target: Option<(u64, usize)>, } impl Dialog { @@ -49,6 +51,8 @@ impl Dialog { search: String::new(), list_index: 0, result: None, + picture_inbox: None, + picture_target: None, } } @@ -63,6 +67,8 @@ impl Dialog { search: String::new(), list_index: 0, result: None, + picture_inbox: None, + picture_target: None, } } @@ -548,7 +554,18 @@ impl Dialog { ], json!({"chart": p("chart")}), ), - "insertPicture" => Dialog::custom("insertPicture", "Insert Picture", json!({"path": ""})), + "insertPicture" => { + let placement = params.get("placement").and_then(Json::as_str).unwrap_or("overCells"); + let mut dialog = Dialog::form( + "insertPicture", + if placement == "cell" { "Place Picture in Cell" } else { "Place Picture over Cells" }, + "insert.picture", + vec![], + json!({"path": "", "placement": placement, "at": active}), + ); + dialog.picture_target = app.view_key(); + dialog + } "pickList" => Dialog::custom("pickList", "Pick From List", json!({})), "saveCopy" => Dialog::custom("saveCopy", "Save a Copy", json!({"format": p("format")})), "saveChanges" => Dialog::custom("saveChanges", "Save Changes?", json!({"title": p("title")})), @@ -641,14 +658,15 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { "insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" => 520.0, _ => 380.0, }; - egui::Window::new(d.title.clone()) + let window = egui::Window::new(d.title.clone()) .id(egui::Id::new("dialog").with(&d.name)) .collapsible(false) .resizable(false) .default_width(width) .anchor(egui::Align2::CENTER_CENTER, vec2(0.0, -40.0)) - .open(&mut win_open) - .show(ctx, |ui| { + .open(&mut win_open); + let window = if d.name == "insertPicture" { window.default_height(240.0) } else { window }; + window.show(ctx, |ui| { ui.set_min_width(width - 20.0); match d.name.as_str() { "formatCells" => format_cells(app, ui, &mut d, &mut confirm), @@ -687,20 +705,55 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { }); } "insertPicture" => { - ui.label("Picture file path (PNG or JPEG):"); - let mut path = d.values.get("path").and_then(Json::as_str).unwrap_or("").to_string(); - ui.text_edit_singleline(&mut path); - d.values.insert("path".into(), json!(path)); - if let Some(pick) = &app.services.pick_open - && ui.button("Browse…").clicked() - && let Some(p) = pick() - { - d.values.insert("path".into(), json!(p)); + // A private inbox dies with this dialog; late browser results + // cannot affect a later picture choice or another workbook. + let arrived = d.picture_inbox.as_ref().and_then(|inbox| { + inbox.lock().unwrap_or_else(std::sync::PoisonError::into_inner).pop() + }); + if let Some((name, bytes)) = arrived { + d.picture_inbox = None; + if bytes.len() > 16 * 1024 * 1024 { + app.message = Some((d.title.clone(), "Choose a PNG or JPEG no larger than 16 MB.".into())); + } else { + d.values.remove("path"); + d.values.insert("alt".into(), json!(name)); + d.values.insert("base64".into(), json!(gridcraft_engine::io::base64_encode(&bytes))); + } + } + if app.services.pick_picture_async.is_some() { + let name = d.values.get("alt").and_then(Json::as_str).unwrap_or("No picture selected"); + ui.label(name); + } else { + ui.label("Picture file path (PNG or JPEG):"); + let mut path = d.values.get("path").and_then(Json::as_str).unwrap_or("").to_string(); + if ui.text_edit_singleline(&mut path).changed() { + d.values.remove("base64"); + d.values.insert("path".into(), json!(path)); + } + } + if (app.services.pick_picture.is_some() || app.services.pick_picture_async.is_some()) && ui.button("Browse…").clicked() { + if let Some(start) = &app.services.pick_picture_async { + let inbox = crate::Inbox::default(); + start(inbox.clone()); + d.picture_inbox = Some(inbox); + } else if let Some(pick) = &app.services.pick_picture + && let Some(path) = pick() + { + d.values.remove("base64"); + let alt = std::path::Path::new(&path).file_name().and_then(|name| name.to_str()).unwrap_or(""); + d.values.insert("alt".into(), json!(alt)); + d.values.insert("path".into(), json!(path)); + } + } + ui.label("Description (optional):"); + let mut alt = d.values.get("alt").and_then(Json::as_str).unwrap_or("").to_string(); + if ui.text_edit_singleline(&mut alt).changed() { + d.values.insert("alt".into(), json!(alt)); + } + if d.values.get("placement").and_then(Json::as_str) == Some("cell") { + ui.label(egui::RichText::new("Fits inside the selected cell and moves with its content.").small()); } ok_cancel(ui, &mut confirm, &mut open); - if confirm { - app.run_or_alert("insert.picture", json!({"path": d.values.get("path")})); - } } "pickList" => { let items: Vec = app @@ -814,6 +867,10 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { if ctx.input(|i| i.key_pressed(Key::Escape)) || !win_open { open = false; } + if confirm && d.name == "insertPicture" && d.picture_target != app.view_key() { + app.message = Some((d.title.clone(), "Return to the original worksheet to insert this picture, or cancel and choose another cell.".into())); + confirm = false; + } if confirm && let Some(cmd) = d.command { let params = build_params(&d); match app.run(cmd, params) { diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index dace53498..1c658ac23 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -66,6 +66,9 @@ fn active_input(app: &SheetApp) -> String { let Some(d) = app.session.active() else { return String::new() }; let Some(sh) = d.wb.active() else { return String::new() }; let a = sh.merge_at(d.selection.active).map(|m| m.start).unwrap_or(d.selection.active); + if let Some(picture) = sh.cell_pictures.get(&a) { + return if picture.alt.is_empty() { "Picture in cell".into() } else { format!("Picture: {}", picture.alt) }; + } if let Some(c) = sh.cell(a) { if c.formula.is_some() && d.wb.styles.get(c.style).protection.hidden && sh.is_protected() { return String::new(); diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 9fa1ca179..b5ca63fe0 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -544,6 +544,10 @@ fn paint_quadrant(p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet, // Text and per-cell decorations. for (c, rect, lk, cfl) in &looks { let c = *c; + if let Some(picture) = sh.cell_pictures.get(&c) { + crate::cell_pictures::paint(p, *rect, picture); + continue; + } let v = sh.value(c); if let Some(l) = cfl && let Some((frac, rgb, gradient)) = l.bar diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 990190687..10cda3a7a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -7,6 +7,7 @@ #![forbid(unsafe_code)] mod border_preview; +mod cell_pictures; pub mod chartview; pub mod control; pub mod credits; @@ -94,6 +95,11 @@ pub struct SheetView { pub struct Services { pub pick_open: Option Option>>, pub pick_save: Option Option>>, + /// Native picture picker (PNG/JPEG), separate from workbook opening. + pub pick_picture: Option Option>>, + /// Browser picture picker. Each dialog owns its inbox, so canceled picks cannot + /// insert into a subsequent dialog or open the image as a workbook. + pub pick_picture_async: Option>, pub open_url: Option>, /// Web: deliver a file to the user (name, bytes). pub download: Option>, @@ -318,7 +324,13 @@ impl SheetApp { let Some(d) = self.session.active() else { return }; let Some(sh) = d.wb.active() else { return }; let at = sh.merge_at(d.selection.active).map(|m| m.start).unwrap_or(d.selection.active); - let current = sh.cell(at).map(|c| c.input_text()).unwrap_or_default(); + // A picture has no editable scalar text. Selecting it, F2, or clicking + // the formula bar must not turn its fallback value into a text edit. + // Typing still starts an intentional replacement. + if text.is_none() && sh.cell_pictures.contains_key(&at) { + return; + } + let current = sh.input_text(at); let (text, replace) = match text { Some(t) => (t, true), None => (current.clone(), false), diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 69fc8808c..d22804c7d 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1029,9 +1029,15 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) { } sep(ui); let pic = big_button(ui, Icon::Picture, "Pictures", "Insert a picture", true); - if pic.clicked() { - app.open_dialog("insertPicture", json!({})); - } + pic.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), "Pictures")); + egui::Popup::menu(&pic).show(|ui| { + for (label, placement) in [("Place in Cell", "cell"), ("Place over Cells", "overCells")] { + if ui.button(label).clicked() { + app.open_dialog("insertPicture", json!({"placement": placement})); + ui.close(); + } + } + }); let shapes = big_button(ui, Icon::Shapes, "Shapes", "Shapes", true); egui::Popup::menu(&shapes).show(|ui| { for (label, kind) in [ diff --git a/crates/ui-egui/tests/cell_pictures.rs b/crates/ui-egui/tests/cell_pictures.rs new file mode 100644 index 000000000..c35bdf054 --- /dev/null +++ b/crates/ui-egui/tests/cell_pictures.rs @@ -0,0 +1,156 @@ +//! Original synthetic pictures exercise the UI without an external image fixture. + +use std::io::Cursor; +use std::sync::{Arc, Mutex}; + +use egui::{Rect, Shape, TextureId, vec2}; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_ui_egui::{Inbox, Services, SheetApp, grid::Geo}; +use serde_json::json; + +fn png() -> Vec { + let image = image::RgbaImage::from_fn(120, 60, |x, _| { + if x < 40 { + image::Rgba([24, 150, 110, 255]) + } else if x < 80 { + image::Rgba([245, 190, 55, 255]) + } else { + image::Rgba([62, 104, 204, 255]) + } + }); + let mut bytes = Cursor::new(Vec::new()); + image.write_to(&mut bytes, image::ImageFormat::Png).unwrap(); + bytes.into_inner() +} + +fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> { + let mut session = Session::new(); + session.new_workbook(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + SheetApp::new(session, services), + ); + h.run_steps(4); + h +} + +fn confirm(h: &mut egui_kittest::Harness<'static, SheetApp>) { + h.get_by_label(" OK ").click(); + h.run_steps(6); +} + +fn image_meshes(h: &egui_kittest::Harness<'static, SheetApp>) -> Vec<(Rect, Rect)> { + h.output() + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + Shape::Mesh(mesh) if mesh.texture_id != TextureId::default() => { + let mut bounds = Rect::NOTHING; + for vertex in &mesh.vertices { + bounds.extend_with(vertex.pos); + } + Some((bounds, shape.clip_rect)) + } + _ => None, + }) + .collect() +} + +#[test] +fn picture_file_pickers_preserve_placement_and_isolate_canceled_results() { + let bytes = png(); + let path = std::env::temp_dir().join(format!("gridcraft-cell-picture-ui-{}.png", std::process::id())); + std::fs::write(&path, &bytes).unwrap(); + let chosen = path.to_string_lossy().to_string(); + let mut h = harness(Services { pick_picture: Some(Box::new(move || Some(chosen.clone()))), ..Default::default() }); + h.state_mut().ui.ribbon_tab = "Insert".into(); + h.run_steps(6); + h.get_by_label("Pictures").click(); + h.run_steps(6); + h.get_by_label("Place in Cell").click(); + h.run_steps(6); + h.get_by_label("Browse…").click(); + h.run_steps(6); + // Moving the selection while the chooser is open must not retarget the insert. + h.state_mut().run("selection.set", json!({"cell": "B2"})).unwrap(); + confirm(&mut h); + let sheet = h.state().session.active().unwrap().wb.active().unwrap(); + assert_eq!(sheet.cell_pictures.get(&CellRef::new(0, 0)).unwrap().data, bytes); + assert!(sheet.cell(CellRef::new(1, 1)).is_none()); + assert!(sheet.images.is_empty()); + std::fs::remove_file(path).unwrap(); + + let requests: Arc>> = Arc::default(); + let send = requests.clone(); + let mut web = harness(Services { pick_picture_async: Some(Box::new(move |inbox| send.lock().unwrap().push(inbox))), ..Default::default() }); + web.state_mut().open_dialog("insertPicture", json!({"placement": "cell"})); + web.run_steps(6); + web.get_by_label("Browse…").click(); + web.run_steps(6); + let canceled = requests.lock().unwrap().pop().unwrap(); + web.state_mut().dialog = None; + web.state_mut().open_dialog("insertPicture", json!({"placement": "overCells"})); + canceled.lock().unwrap().push(("late.png".into(), bytes.clone())); + web.run_steps(6); + assert!(!web.state().dialog.as_ref().unwrap().values.contains_key("base64")); + web.get_by_label("Browse…").click(); + web.run_steps(6); + requests.lock().unwrap().pop().unwrap().lock().unwrap().push(("stripes.png".into(), bytes)); + web.run_steps(6); + confirm(&mut web); + let sheet = web.state().session.active().unwrap().wb.active().unwrap(); + assert_eq!(sheet.images.len(), 1); + assert_eq!(sheet.images[0].alt, "stripes.png"); + assert!(sheet.cell(CellRef::new(0, 0)).is_none()); +} + +#[test] +fn pictures_fit_current_geometry_and_selection_cannot_erase_them() { + let mut h = harness(Default::default()); + h.state_mut() + .run("insert.picture", json!({"base64": gridcraft_engine::io::base64_encode(&png()), "placement": "cell", "alt": "Three stripes"})) + .unwrap(); + h.state_mut().run("home.rowHeight", json!({"height": 100})).unwrap(); + h.state_mut().run("home.columnWidth", json!({"width": 150})).unwrap(); + h.run_steps(6); + let meshes = image_meshes(&h); + assert_eq!(meshes.len(), 1); + assert!((meshes[0].0.width() / meshes[0].0.height() - 2.0).abs() < 0.001); + let d = h.state().session.active().unwrap(); + let sheet = d.wb.active().unwrap(); + let geo = Geo::new(sheet, h.state().grid.rect.unwrap(), h.state().view().scroll); + let cell = geo.cell_rect(sheet, CellRef::new(0, 0)); + assert!(cell.contains_rect(meshes[0].0)); + assert_eq!(cell.center(), meshes[0].0.center()); + h.get_by_label("Picture: Three stripes"); + h.state_mut().begin_edit(None, false); + assert!(h.state().editor.is_none()); + h.state_mut().begin_edit(None, true); + assert!(h.state().editor.is_none()); + + h.state_mut().run("home.mergeCells", json!({"range": "A1:B2"})).unwrap(); + h.state_mut().run("view.zoom", json!({"percent": 150})).unwrap(); + h.state_mut().run("view.freezePanes", json!({"cell": "C3"})).unwrap(); + h.run_steps(6); + let resized = image_meshes(&h); + assert_eq!(resized.len(), 1); + assert!(resized[0].0.width() > meshes[0].0.width()); + assert!((resized[0].0.width() / resized[0].0.height() - 2.0).abs() < 0.001); + + h.state_mut().run("home.unmergeCells", json!({"range": "A1:B2"})).unwrap(); + h.state_mut().run("home.rowHeight", json!({"rows": "A1", "height": 0})).unwrap(); + h.run_steps(6); + assert!(image_meshes(&h).is_empty()); + h.state_mut().run("home.rowHeight", json!({"rows": "A1", "height": 100})).unwrap(); + h.state_mut().begin_edit(Some("replacement".into()), false); + h.state_mut().commit_edit(0, 0, false, false); + h.run_steps(6); + let sheet = h.state().session.active().unwrap().wb.active().unwrap(); + assert_eq!(sheet.value(CellRef::new(0, 0)), Value::from("replacement")); + assert!(!sheet.cell_pictures.contains_key(&CellRef::new(0, 0))); +} diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 7c724763a..fbd8c13f8 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -17,6 +17,7 @@ mod fmla; mod package; mod pivot; mod read; +mod richdata; mod sheet_read; mod sheet_write; mod styles; diff --git a/crates/xlsx/src/package.rs b/crates/xlsx/src/package.rs index e6cade668..9a2899906 100644 --- a/crates/xlsx/src/package.rs +++ b/crates/xlsx/src/package.rs @@ -58,22 +58,28 @@ impl<'a> Package<'a> { /// Reads a part's bytes; `Ok(None)` when it doesn't exist. pub fn read(&mut self, part: &str) -> Result>, IoError> { + self.read_limited(part, MAX_PART) + } + + /// Reads a small metadata part without allowing it to expand to the general part limit. + pub fn read_limited(&mut self, part: &str, maximum: u64) -> Result>, IoError> { + let maximum = maximum.min(MAX_PART); let Some(&idx) = self.names.get(&normalize(part)) else { return Ok(None); }; let mut f = self.zip.by_index(idx).map_err(|e| IoError::Zip(format!("{part}: {e}")))?; - if f.size() > MAX_PART { + if f.size() > maximum { return Err(IoError::TooLarge(format!("{part} expands to {} bytes", f.size()))); } let remaining = MAX_TOTAL.saturating_sub(self.total); - let limit = MAX_PART.min(remaining); + let limit = maximum.min(remaining); let mut out = Vec::with_capacity(f.size().min(16 * 1024 * 1024) as usize); (&mut f).take(limit + 1).read_to_end(&mut out).map_err(|e| IoError::Zip(format!("{part}: {e}")))?; if out.len() as u64 > limit { - return Err(IoError::TooLarge(if limit < MAX_PART { + return Err(IoError::TooLarge(if remaining < maximum { "package expands to more than 1 GB".into() } else { - format!("{part} expands to more than 512 MB") + format!("{part} expands to more than {maximum} bytes") })); } self.total += out.len() as u64; diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index bf45e91fa..072998068 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use gridcraft_core::{DateSystem, RangeRef}; -use gridcraft_model::{CalcMode, DefinedName, Sheet, Style, StyleId, StyleTable, Visibility, Workbook}; +use gridcraft_model::{CalcMode, CellPicture, DefinedName, Sheet, Style, StyleId, StyleTable, Visibility, Workbook}; use crate::package::{Package, Rel}; use crate::styles::{StylesIn, read_styles}; @@ -26,6 +26,8 @@ pub struct Ctx<'a> { pub next_id: u32, /// Custom number formats from the styles part, by id. pub num_fmts: std::collections::HashMap, + /// Value metadata blocks, indexed by the worksheet's one-based `vm` attribute. + pub cell_pictures: Vec>>, } impl Ctx<'_> { @@ -76,6 +78,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { persons: Default::default(), next_id: 1, num_fmts: Default::default(), + cell_pictures: vec![], }; let root_rels = cx.pkg.rels("")?; let wb_part = find_rel(&root_rels, "officeDocument").map(|r| r.target.clone()).unwrap_or_else(|| "xl/workbook.xml".into()); @@ -95,6 +98,13 @@ 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)?; + cx.cell_pictures = match crate::richdata::read(&mut cx, &wb_rels) { + Ok(pictures) => pictures, + Err(error) => { + cx.warn(format!("cell-picture data could not be read ({error}); scalar fallbacks were retained")); + vec![] + } + }; let mut wb = Workbook::new(); wb.sheets.clear(); diff --git a/crates/xlsx/src/richdata.rs b/crates/xlsx/src/richdata.rs new file mode 100644 index 000000000..8a4f6ec0a --- /dev/null +++ b/crates/xlsx/src/richdata.rs @@ -0,0 +1,368 @@ +//! Static in-cell PNG/JPEG images, from the public MS-XLSX rich-data schemas. +//! `vm` and metadata type IDs are one-based; every subsequent table index is zero-based. + +use std::collections::HashMap; +use std::fmt::Write as _; +use std::sync::Arc; + +use gridcraft_model::CellPicture; + +use crate::IoError; +use crate::package::{Rel, normalize}; +use crate::read::Ctx; +use crate::write::{NS_MAIN, NS_REL, Out, Rels, XML_DECL}; +use crate::xml::{self, El, esc}; + +const NS_RICH: &str = "http://schemas.microsoft.com/office/spreadsheetml/2017/richdata"; +const NS_IMAGE_REL: &str = "http://schemas.microsoft.com/office/spreadsheetml/2022/richvaluerel"; +const RICH_EXT: &str = "{3E2802C4-A4D2-4D8B-9148-E3BE6C30E623}"; +const MAX_IMAGE: u64 = 16 * 1024 * 1024; +const MAX_IMAGES_TOTAL: usize = 128 * 1024 * 1024; +const MAX_RECORDS: usize = 100_000; +const MAX_XML: u64 = 16 * 1024 * 1024; +const MAX_PIXELS: u64 = 16_000_000; + +/// Header dimensions bound decode allocations in consumers. Full decoding is left to the UI. +fn image_type(data: &[u8]) -> Option<(&'static str, &'static str)> { + let dimensions = if data.len() >= 33 && data.starts_with(b"\x89PNG\r\n\x1a\n") && data.get(8..16)? == b"\0\0\0\rIHDR" { + let w = u32::from_be_bytes(data.get(16..20)?.try_into().ok()?); + let h = u32::from_be_bytes(data.get(20..24)?.try_into().ok()?); + Some((w, h, "image/png", "png")) + } else if data.starts_with(&[0xff, 0xd8]) { + jpeg_dimensions(data).map(|(w, h)| (w, h, "image/jpeg", "jpeg")) + } else { + None + }; + let (w, h, mime, ext) = dimensions?; + (w > 0 && h > 0 && w <= 8192 && h <= 8192 && u64::from(w) * u64::from(h) <= MAX_PIXELS).then_some((mime, ext)) +} + +fn jpeg_dimensions(data: &[u8]) -> Option<(u32, u32)> { + let mut offset = 2usize; + while offset < data.len() { + if *data.get(offset)? != 0xff { + return None; + } + while data.get(offset) == Some(&0xff) { + offset += 1; + } + let marker = *data.get(offset)?; + offset += 1; + if matches!(marker, 0xd9 | 0xda) { + return None; + } + if marker == 0x01 || (0xd0..=0xd7).contains(&marker) { + continue; + } + let len = usize::from(u16::from_be_bytes(data.get(offset..offset.checked_add(2)?)?.try_into().ok()?)); + let end = offset.checked_add(len)?; + if len < 2 || end > data.len() { + return None; + } + if matches!(marker, 0xc0..=0xc3 | 0xc5..=0xc7 | 0xc9..=0xcb | 0xcd..=0xcf) && len >= 8 { + let h = u16::from_be_bytes(data.get(offset + 3..offset + 5)?.try_into().ok()?); + let w = u16::from_be_bytes(data.get(offset + 5..offset + 7)?.try_into().ok()?); + return Some((u32::from(w), u32::from(h))); + } + offset = end; + } + None +} + +fn part(cx: &mut Ctx<'_>, rels: &[Rel], kind: &str) -> Option { + let mut target: Option = None; + for rel in rels.iter().filter(|rel| rel.kind == kind) { + if rel.external { + cx.warn(format!("external {kind} relationship is unsupported; cell pictures may be missing")); + return None; + } + if target.as_ref().is_some_and(|old| normalize(old) != normalize(&rel.target)) { + cx.warn(format!("conflicting {kind} relationships; cell pictures may be missing")); + return None; + } + target = Some(rel.target.clone()); + } + target +} + +fn read_xml(cx: &mut Ctx<'_>, path: &str, root: &str) -> Result, IoError> { + let bytes = match cx.pkg.read_limited(path, MAX_XML) { + Ok(Some(bytes)) => bytes, + Err(error @ IoError::TooLarge(_)) => return Err(error), + other => { + cx.warn(format!("missing or damaged cell-picture part {path}: {}", other.err().map(|e| e.to_string()).unwrap_or_default())); + return Ok(None); + } + }; + match xml::parse(&bytes) { + Ok(el) if el.name == root => Ok(Some(el)), + _ => { + cx.warn(format!("invalid cell-picture part {path}")); + Ok(None) + } + } +} + +fn entries<'a>(cx: &mut Ctx<'_>, el: &'a El, child: &'a str) -> Result, IoError> { + let entries: Vec<_> = el.kids(child).take(MAX_RECORDS + 1).collect(); + if entries.len() > MAX_RECORDS { + return Err(IoError::TooLarge("too many cell-picture metadata records".into())); + } + if el.attr("count").is_some() && el.attr_u32("count").map(|n| n as usize) != Some(entries.len()) { + cx.warn(format!("cell-picture {} count does not match its records", el.name)); + } + Ok(entries) +} + +/// Resolves every value-metadata block without assuming the indices at each layer coincide. +pub fn read(cx: &mut Ctx<'_>, workbook_rels: &[Rel]) -> Result>>, IoError> { + let Some(metadata_path) = part(cx, workbook_rels, "sheetMetadata") else { return Ok(vec![]) }; + let Some(metadata) = read_xml(cx, &metadata_path, "metadata")? else { return Ok(vec![]) }; + let Some(types) = metadata.child("metadataTypes") else { return Ok(vec![]) }; + let types = entries(cx, types, "metadataType")?; + let Some(type_id) = types.iter().position(|el| el.attr("name") == Some("XLRICHVALUE")).map(|n| n + 1) else { return Ok(vec![]) }; + if types.iter().filter(|el| el.attr("name") == Some("XLRICHVALUE")).count() != 1 { + cx.warn("duplicate XLRICHVALUE metadata types; cell pictures were not loaded"); + return Ok(vec![]); + } + // Support workbook-owned parts and the implicit owner chain described by MS-XLSX. + let mut rels = workbook_rels.to_vec(); + rels.extend(cx.pkg.rels(&metadata_path)?); + let Some(data_path) = part(cx, &rels, "rdRichValue") else { + cx.warn("missing rich-value data relationship; cell pictures were not loaded"); + return Ok(vec![]); + }; + rels.extend(cx.pkg.rels(&data_path)?); + let (Some(structure_path), Some(image_rels_path)) = (part(cx, &rels, "rdRichValueStructure"), part(cx, &rels, "richValueRel")) else { + cx.warn("missing rich-value structure or image relationships; cell pictures were not loaded"); + return Ok(vec![]); + }; + let Some(data) = read_xml(cx, &data_path, "rvData")? else { return Ok(vec![]) }; + let Some(structures) = read_xml(cx, &structure_path, "rvStructures")? else { return Ok(vec![]) }; + let Some(image_rel_list) = read_xml(cx, &image_rels_path, "richValueRels")? else { return Ok(vec![]) }; + let image_rels = cx.pkg.rels(&image_rels_path)?; + let structures = entries(cx, &structures, "s")?; + let image_rel_list = entries(cx, &image_rel_list, "rel")?; + let values = entries(cx, &data, "rv")?; + let mut pictures = Vec::with_capacity(values.len()); + let mut cache = HashMap::new(); + let mut total = 0usize; + for value in values { + let picture = read_value(cx, value, &structures, &image_rel_list, &image_rels, &mut cache, &mut total)?; + if picture.is_none() { + cx.warn("some rich values are unsupported or malformed; their scalar fallbacks were retained (cell pictures may be missing)"); + } + pictures.push(picture); + } + let Some(future) = metadata.kids("futureMetadata").find(|el| el.attr("name") == Some("XLRICHVALUE")) else { + cx.warn("missing XLRICHVALUE future metadata; cell pictures were not loaded"); + return Ok(vec![]); + }; + let future: Vec<_> = entries(cx, future, "bk")? + .into_iter() + .map(|block| { + block + .child("extLst") + .and_then(|el| el.kids("ext").find(|ext| ext.attr("uri").is_some_and(|uri| uri.eq_ignore_ascii_case(RICH_EXT)))) + .and_then(|ext| ext.child("rvb")) + .and_then(|rvb| rvb.attr_u32("i")) + .map(|n| n as usize) + }) + .collect(); + let Some(blocks) = metadata.child("valueMetadata") else { return Ok(vec![]) }; + let blocks = entries(cx, blocks, "bk")?; + Ok(blocks + .into_iter() + .map(|block| { + block + .kids("rc") + .find(|rc| rc.attr_u32("t").map(|n| n as usize) == Some(type_id)) + .and_then(|rc| rc.attr_u32("v")) + .and_then(|n| future.get(n as usize)) + .copied() + .flatten() + .and_then(|n| pictures.get(n)) + .cloned() + .flatten() + }) + .collect()) +} + +fn read_value( + cx: &mut Ctx<'_>, + value: &El, + structures: &[&El], + image_rel_list: &[&El], + image_rels: &[Rel], + cache: &mut HashMap<(String, String), Arc>, + total: &mut usize, +) -> Result>, IoError> { + let Some(structure) = value.attr_u32("s").and_then(|n| structures.get(n as usize)) else { return Ok(None) }; + if structure.attr("t") != Some("_localImage") { + return Ok(None); + } + let keys: Vec<_> = structure.kids("k").collect(); + let values: Vec<_> = value.kids("v").collect(); + if keys.len() != values.len() { + return Ok(None); + } + let lookup = |name: &str, ty: &str| { + let mut matches = keys.iter().zip(&values).filter(|(k, _)| k.attr("n") == Some(name)); + let (key, value) = matches.next()?; + (matches.next().is_none() && key.attr("t") == Some(ty)).then_some(value.text.as_str()) + }; + let Some(rel_id) = lookup("_rvRel:LocalImageIdentifier", "i") + .and_then(|v| v.trim().parse::().ok()) + .and_then(|n| image_rel_list.get(n)) + .and_then(|rel| rel.attr("id")) + else { + return Ok(None); + }; + let mut matches = image_rels.iter().filter(|rel| rel.id == rel_id); + let Some(rel) = matches.next() else { return Ok(None) }; + if matches.next().is_some() || rel.kind != "image" || rel.external { + cx.warn("a cell picture has an external or invalid image relationship; kept its scalar fallback"); + return Ok(None); + } + let alt = lookup("Text", "s").unwrap_or("").to_string(); + let key = (normalize(&rel.target), alt.clone()); + if let Some(picture) = cache.get(&key) { + return Ok(Some(picture.clone())); + } + let data = match cx.pkg.read_limited(&rel.target, MAX_IMAGE) { + Ok(Some(data)) => data, + other => { + cx.warn(format!( + "missing, damaged or oversized cell picture {}: {}; kept its scalar fallback", + rel.target, + other.err().map(|e| e.to_string()).unwrap_or_default() + )); + return Ok(None); + } + }; + let Some((mime, _)) = image_type(&data) else { + cx.warn("a cell picture is not a supported PNG/JPEG or exceeds 8192 pixels per side / 16 million pixels; kept its scalar fallback"); + return Ok(None); + }; + if total.saturating_add(data.len()) > MAX_IMAGES_TOTAL { + cx.warn("cell pictures exceed the 128 MiB workbook limit; remaining pictures kept their scalar fallbacks"); + return Ok(None); + } + *total += data.len(); + let picture = Arc::new(CellPicture { data, mime: mime.to_string(), alt }); + cache.insert(key, picture.clone()); + Ok(Some(picture)) +} + +/// One rich value per shared model payload; copied cells can reuse a metadata block. +#[derive(Default)] +pub struct PicturesOut { + pictures: Vec>, + indices: HashMap<*const CellPicture, u32>, +} + +impl PicturesOut { + pub fn is_empty(&self) -> bool { + self.pictures.is_empty() + } + pub fn index(&mut self, picture: &Arc) -> u32 { + let key = Arc::as_ptr(picture); + if let Some(index) = self.indices.get(&key) { + return *index; + } + let index = self.pictures.len().saturating_add(1) as u32; + self.pictures.push(picture.clone()); + self.indices.insert(key, index); + index + } +} + +pub fn write(out: &mut Out, workbook_rels: &mut Rels) -> Result<(), IoError> { + let pictures = std::mem::take(&mut out.cell_pictures).pictures; + if pictures.len() > MAX_RECORDS { + return Err(IoError::TooLarge("too many cell pictures".into())); + } + let count = pictures.len(); + let mut data = format!("{XML_DECL}"); + let mut rel_list = format!("{XML_DECL}"); + let mut image_rels = Rels::default(); + let mut total = 0usize; + for (i, picture) in pictures.iter().enumerate() { + total = total.saturating_add(picture.data.len()); + if picture.data.len() as u64 > MAX_IMAGE || total > MAX_IMAGES_TOTAL { + return Err(IoError::TooLarge("cell pictures exceed the 16 MiB image or 128 MiB workbook limit".into())); + } + let (mime, ext) = image_type(&picture.data) + .ok_or_else(|| IoError::Format("cell pictures must be PNG/JPEG with at most 8192 pixels per side and 16 million pixels".into()))?; + let image_name = format!("cellImage{}.{}", i + 1, ext); + out.default_type(ext, mime); + out.part(&format!("xl/media/{image_name}"), None, picture.data.clone()); + let rid = image_rels.add("image", &format!("../media/{image_name}")); + let _ = write!(rel_list, ""); + let _ = write!(data, "{i}5{}", esc(&picture.alt)); + } + data.push_str(""); + rel_list.push_str(""); + let structures = format!( + "{XML_DECL}" + ); + for (name, content_type, relation, bytes) in [ + ( + "rdrichvalue.xml", + "application/vnd.ms-excel.rdRichValue+xml", + "http://schemas.microsoft.com/office/2017/06/relationships/rdRichValue", + data.into_bytes(), + ), + ( + "rdrichvaluestructure.xml", + "application/vnd.ms-excel.rdRichValueStructure+xml", + "http://schemas.microsoft.com/office/2017/06/relationships/rdRichValueStructure", + structures.into_bytes(), + ), + ( + "richValueRel.xml", + "application/vnd.ms-excel.richvaluerel+xml", + "http://schemas.microsoft.com/office/2022/10/relationships/richValueRel", + rel_list.into_bytes(), + ), + ] { + out.part(&format!("xl/richData/{name}"), Some(content_type), bytes); + workbook_rels.add_uri(relation, &format!("richData/{name}")); + } + out.part("xl/richData/_rels/richValueRel.xml.rels", None, image_rels.xml().into_bytes()); + out.part( + "xl/metadata.xml", + Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheetMetadata+xml"), + metadata(count, out.dynamic_arrays).into_bytes(), + ); + workbook_rels.add("sheetMetadata", "metadata.xml"); + Ok(()) +} + +fn metadata(count: usize, dynamic: bool) -> String { + let type_id = if dynamic { 2 } else { 1 }; + let mut xml = format!( + "{XML_DECL}" + ); + if dynamic { + xml.push_str(""); + } + xml.push_str(""); + if dynamic { + xml.push_str(""); + } + let _ = write!(xml, ""); + for i in 0..count { + let _ = write!(xml, ""); + } + xml.push_str(""); + if dynamic { + xml.push_str(""); + } + let _ = write!(xml, ""); + for i in 0..count { + let _ = write!(xml, ""); + } + xml.push_str(""); + xml +} diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 9f382b15c..dcc2f4902 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -362,6 +362,24 @@ fn read_cell(cx: &mut Ctx<'_>, sheet: &mut Sheet, st: &mut RowState, c: &El, pos None => Value::Empty, }, }; + let picture = c.attr("vm").and_then(|vm| { + if vm == "0" { + return None; + } + let picture = vm.parse::().ok().and_then(|n| n.checked_sub(1)).and_then(|n| cx.cell_pictures.get(n)).cloned().flatten(); + if picture.is_none() { + cx.warn("some cells have unsupported or missing value metadata; kept their scalar fallback (cell pictures may be missing)"); + } + picture + }); + if let Some(picture) = picture { + if c.child("f").is_some() { + cx.warn("formula-generated cell pictures were imported as static pictures; their formulas were not retained"); + } + sheet.set_picture(pos, picture); + sheet.set_style(pos, style); + return; + } let dynamic = c.attr("cm").is_some(); let formula = c.child("f").and_then(|f| read_formula(cx, st, f, pos, dynamic)); let value = if formula.is_none() && st.dynamic.iter().any(|r| r.contains(pos) && r.start != pos) { Value::Empty } else { value }; diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index 86999d141..b05f1ff3e 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -251,6 +251,7 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( // sheetData let mut keys: BTreeSet = sheet.cells.iter().map(|(c, _)| c).collect(); + keys.extend(sheet.cell_pictures.keys().copied()); keys.extend(extra.keys().copied()); keys.extend(overrides.keys().copied()); let mut rows: BTreeMap> = BTreeMap::new(); @@ -291,7 +292,7 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( let cell = sheet.cells.get(*c); let ov = overrides.get(c); let ex = extra.get(c); - write_cell(&mut s, *c, cell, ov, ex, dynamic.get(c), &sid, out); + write_cell(&mut s, *c, cell, sheet.cell_pictures.get(c), ov, ex, dynamic.get(c), &sid, out); } s.push_str(""); } @@ -536,6 +537,7 @@ fn write_cell( s: &mut String, c: CellRef, cell: Option<&Cell>, + picture: Option<&std::sync::Arc>, ov: Option<&String>, extra: Option<&Value>, dynamic: Option<&RangeRef>, @@ -547,6 +549,11 @@ fn write_cell( if style != 0 { let _ = write!(s, " s=\"{style}\""); } + if let Some(picture) = picture { + let vm = out.cell_pictures.index(picture); + let _ = write!(s, " t=\"e\" vm=\"{vm}\">#VALUE!"); + return; + } let formula = if ov.is_some() { None } else { cell.and_then(|x| x.formula.as_ref()) }; let value: Value = match (ov, extra) { (Some(t), _) => Value::text(t.as_str()), diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index 46bd997a0..b20aabaa0 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -4,6 +4,7 @@ mod csv_tests; mod fixtures; mod malformed; mod pivot_tests; +mod richdata_tests; mod roundtrip; use std::io::Write; diff --git a/crates/xlsx/src/tests/richdata_tests.rs b/crates/xlsx/src/tests/richdata_tests.rs new file mode 100644 index 000000000..c7bd488f7 --- /dev/null +++ b/crates/xlsx/src/tests/richdata_tests.rs @@ -0,0 +1,272 @@ +//! Original images and hand-written package metadata independent of the writer. + +use std::sync::Arc; + +use gridcraft_core::{CellError, CellRef, RangeRef, Value}; +use gridcraft_model::{Cell, CellPicture, Formula, Sheet, Workbook}; + +use super::make_zip; +use crate::package::Package; +use crate::{IoError, read_xlsx, write_xlsx}; + +fn at(value: &str) -> CellRef { + CellRef::parse(value).unwrap() +} + +/// An original 1x1 green RGB PNG. Stored DEFLATE block, Adler-32, and PNG CRC-32. +fn png() -> Vec { + fn chunk(out: &mut Vec, kind: &[u8], body: &[u8]) { + out.extend_from_slice(&(body.len() as u32).to_be_bytes()); + out.extend_from_slice(kind); + out.extend_from_slice(body); + let mut crc = !0u32; + for &byte in kind.iter().chain(body) { + crc ^= u32::from(byte); + for _ in 0..8 { + crc = (crc >> 1) ^ (0xedb88320 & 0u32.wrapping_sub(crc & 1)); + } + } + out.extend_from_slice(&(!crc).to_be_bytes()); + } + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + chunk(&mut png, b"IHDR", &[0, 0, 0, 1, 0, 0, 0, 1, 8, 2, 0, 0, 0]); + let pixel = [0u8, 28, 170, 72]; + let mut zlib = vec![0x78, 0x01, 1, 4, 0, 0xfb, 0xff]; + zlib.extend_from_slice(&pixel); + let (mut a, mut b) = (1u32, 0u32); + for n in pixel { + a += u32::from(n); + b += a; + } + zlib.extend_from_slice(&((b << 16) | a).to_be_bytes()); + chunk(&mut png, b"IDAT", &zlib); + chunk(&mut png, b"IEND", &[]); + png +} + +/// Original one-pixel baseline gray JPEG: zero DCT coefficients, tiny Huffman tables. +fn jpeg() -> Vec { + fn segment(data: &mut Vec, marker: u8, payload: &[u8]) { + data.extend_from_slice(&[0xff, marker]); + data.extend_from_slice(&((payload.len() + 2) as u16).to_be_bytes()); + data.extend_from_slice(payload); + } + let mut jpeg = vec![0xff, 0xd8]; + let mut quant = vec![0]; + quant.extend_from_slice(&[1; 64]); + segment(&mut jpeg, 0xdb, &quant); + segment(&mut jpeg, 0xc0, &[8, 0, 1, 0, 1, 1, 1, 0x11, 0]); + let mut huffman = Vec::new(); + for table in [0, 0x10] { + huffman.extend_from_slice(&[table, 1]); + huffman.extend_from_slice(&[0; 16]); + } + segment(&mut jpeg, 0xc4, &huffman); + segment(&mut jpeg, 0xda, &[1, 1, 0, 0, 63, 0]); + jpeg.extend_from_slice(&[0x3f, 0xff, 0xd9]); + jpeg +} + +fn picture(data: Vec, mime: &str, alt: &str) -> CellPicture { + CellPicture { data, mime: mime.into(), alt: alt.into() } +} + +/// A1 vm2 -> block1/type2/v0 -> future0 -> rv1 -> structure1 -> relation1. +/// The deliberately different indices catch accidentally using vm directly as an image ID. +fn independent_parts(chain: bool) -> Vec<(String, Vec)> { + let mut parts = Vec::new(); + let mut add = |name: &str, xml: &str| parts.push((name.to_string(), xml.as_bytes().to_vec())); + add("[Content_Types].xml", super::CT); + add( + "_rels/.rels", + r#""#, + ); + add( + "pkg/book.xml", + r#""#, + ); + let rich = + r#""#; + let rest = r#""#; + add( + "pkg/_rels/book.xml.rels", + &format!( + r#"{}"#, + if chain { String::new() } else { format!("{rich}{rest}") } + ), + ); + if chain { + add("meta/_rels/state.xml.rels", &format!("{rich}")); + add("rich/_rels/values.xml.rels", &format!("{rest}")); + } + add( + "sheets/data.xml", + r#"#VALUE!_xlfn.SEQUENCE(2)12"#, + ); + add( + "meta/state.xml", + r#""#, + ); + add( + "rich/values.xml", + r#"unusedgreen & <image>15"#, + ); + add( + "rich/structures.xml", + r#""#, + ); + add( + "rich/images.xml", + r#""#, + ); + add( + "rich/_rels/images.xml.rels", + r#""#, + ); + parts.push(("media/original.bin".into(), png())); + parts +} + +fn zip(parts: &[(String, Vec)]) -> Vec { + make_zip(&parts.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect::>()) +} + +fn replace(parts: &mut [(String, Vec)], name: &str, from: &str, to: &str) { + let (_, bytes) = parts.iter_mut().find(|(n, _)| n == name).unwrap(); + let text = std::str::from_utf8(bytes).unwrap().replace(from, to); + *bytes = text.into_bytes(); +} + +#[test] +fn reads_independent_indices_reordered_keys_and_both_relationship_layouts() { + for chain in [false, true] { + let (wb, _) = read_xlsx(&zip(&independent_parts(chain))).unwrap(); + let sheet = wb.sheet(0).unwrap(); + let cell = sheet.cells.get(at("A1")).unwrap(); + assert_eq!(sheet.cell_pictures.get(&at("A1")).map(Arc::as_ref), Some(&picture(png(), "image/png", "green & "))); + assert_eq!(cell.value, Value::Error(CellError::Value)); + assert!(cell.formula.is_none()); + assert!(sheet.cells.get(at("B1")).unwrap().formula.as_ref().unwrap().array.is_none()); + assert!(sheet.cells.get(at("B2")).is_none()); + } +} + +#[test] +fn pictures_roundtrip_across_sheets_with_and_without_dynamic_arrays() { + for dynamic in [false, true] { + let mut wb = Workbook::new(); + let first = Arc::new(picture(png(), "image/png", "green & Ω\nline")); + wb.sheet_mut(0).unwrap().set_picture(at("B2"), first.clone()); + wb.sheet_mut(0).unwrap().set_picture(at("A4"), Arc::new(picture(jpeg(), "image/jpeg", "gray"))); + let mut second = Sheet::new("Second"); + second.set_picture(at("C7"), first.clone()); + wb.sheets.push(Arc::new(second)); + if dynamic { + let sheet = wb.sheet_mut(0).unwrap(); + let mut f = Cell::formula(Formula::new("SEQUENCE(2)")); + f.value = Value::Number(1.0); + sheet.cells.set(at("E1"), f); + sheet.spill_ranges.insert(at("E1"), RangeRef::parse("E1:E2").unwrap()); + sheet.spill.insert(at("E2"), Value::Number(2.0)); + } + let bytes = write_xlsx(&wb).unwrap(); + let mut package = Package::open(&bytes).unwrap(); + let metadata = package.read_xml("xl/metadata.xml").unwrap().unwrap(); + assert_eq!(metadata.child("metadataTypes").unwrap().attr_u32("count"), Some(if dynamic { 2 } else { 1 })); + assert_eq!(metadata.child("valueMetadata").unwrap().attr_u32("count"), Some(2)); + let (loaded, report) = read_xlsx(&bytes).unwrap(); + assert!(report.warnings.is_empty(), "{:?}", report.warnings); + assert_eq!(loaded.sheet(0).unwrap().cell_pictures.get(&at("B2")), Some(&first)); + assert_eq!(loaded.sheet(1).unwrap().cell_pictures.get(&at("C7")), Some(&first)); + assert_eq!(loaded.sheet(0).unwrap().cell_pictures.get(&at("A4")).unwrap().data, jpeg()); + if dynamic { + let f = loaded.sheet(0).unwrap().cells.get(at("E1")).unwrap().formula.as_ref().unwrap(); + assert_eq!(f.text, "SEQUENCE(2)"); + assert!(f.array.is_none()); + } + let rels = package.rels("xl/workbook.xml").unwrap(); + assert!(rels.iter().any(|r| r.kind == "rdRichValue")); + assert!(rels.iter().any(|r| r.kind == "richValueRel")); + let sheet = package.read_xml("xl/worksheets/sheet1.xml").unwrap().unwrap(); + let picture_cell = sheet.child("sheetData").unwrap().kids("row").flat_map(|row| row.kids("c")).find(|c| c.attr("r") == Some("B2")).unwrap(); + assert_eq!(picture_cell.attr("vm"), Some("1")); + assert_eq!(picture_cell.attr("t"), Some("e")); + assert_eq!(picture_cell.child("v").unwrap().text, "#VALUE!"); + } +} + +#[test] +fn broken_picture_references_keep_the_scalar_fallback_with_warnings() { + for (part, from, to) in [ + ("sheets/data.xml", "vm=\"2\"", "vm=\"999\""), + ("meta/state.xml", "", ""), + ("meta/state.xml", "", ""), + ("rich/values.xml", "", ""), + ("rich/values.xml", "1", "99"), + ("rich/_rels/images.xml.rels", "Target=\"../media/original.bin\"", "Target=\"https://example.invalid/image.png\" TargetMode=\"External\""), + ("rich/_rels/images.xml.rels", "original.bin", "missing.bin"), + ("rich/structures.xml", "t=\"_localImage\"", "t=\"unsupportedImage\""), + ("rich/images.xml", "opaqueImageId", "missingImageId"), + ] { + let mut parts = independent_parts(false); + replace(&mut parts, part, from, to); + let (wb, report) = read_xlsx(&zip(&parts)).unwrap(); + let cell = wb.sheet(0).unwrap().cells.get(at("A1")).unwrap(); + assert!(!wb.sheet(0).unwrap().cell_pictures.contains_key(&at("A1")), "{part}: {to}"); + assert_eq!(cell.value, Value::Error(CellError::Value)); + assert!(report.warnings.iter().any(|w| w.contains("picture")), "{:?}", report.warnings); + } +} + +#[test] +fn hostile_image_bytes_and_dimensions_are_bounded() { + let mut parts = independent_parts(false); + let (_, image) = parts.iter_mut().find(|(n, _)| n == "media/original.bin").unwrap(); + image[16..20].copy_from_slice(&u32::MAX.to_be_bytes()); + let (wb, report) = read_xlsx(&zip(&parts)).unwrap(); + assert!(!wb.sheet(0).unwrap().cell_pictures.contains_key(&at("A1"))); + assert!(report.warnings.iter().any(|w| w.contains("pixels"))); + let (_, image) = parts.iter_mut().find(|(n, _)| n == "media/original.bin").unwrap(); + image.resize(16 * 1024 * 1024 + 1, 0); + let (wb, report) = read_xlsx(&zip(&parts)).unwrap(); + assert!(!wb.sheet(0).unwrap().cell_pictures.contains_key(&at("A1"))); + assert!(report.warnings.iter().any(|w| w.contains("oversized"))); + let mut wb = Workbook::new(); + wb.sheet_mut(0).unwrap().set_picture(at("A1"), Arc::new(picture(vec![1, 2, 3], "image/png", "invalid"))); + assert!(matches!(write_xlsx(&wb), Err(IoError::Format(_)))); +} + +/// Advertise a large decompressed size without allocating a large fixture. Package reads +/// must reject the header before touching the (deliberately inconsistent) compressed data. +fn oversized_entry(mut bytes: Vec, path: &str, size: u32) -> Vec { + let mut i = 0; + while i + 46 <= bytes.len() { + if bytes[i..].starts_with(b"PK\x01\x02") { + let len = usize::from(u16::from_le_bytes([bytes[i + 28], bytes[i + 29]])); + if bytes.get(i + 46..i + 46 + len) == Some(path.as_bytes()) { + bytes[i + 24..i + 28].copy_from_slice(&size.to_le_bytes()); + return bytes; + } + } + i += 1; + } + panic!("fixture entry not found"); +} + +#[test] +fn picture_metadata_limits_and_relationship_read_errors_do_not_block_workbooks() { + let mut parts = independent_parts(false); + replace(&mut parts, "sheets/data.xml", "", "42"); + let oversized_xml = oversized_entry(zip(&parts), "rich/values.xml", 16 * 1024 * 1024 + 1); + let broken_rels = oversized_entry(zip(&parts), "rich/_rels/images.xml.rels", 512 * 1024 * 1024 + 1); + let (_, values) = parts.iter_mut().find(|(path, _)| path == "rich/values.xml").unwrap(); + *values = format!("{}", "".repeat(100_001)).into_bytes(); + for bytes in [oversized_xml, broken_rels, zip(&parts)] { + let (wb, report) = read_xlsx(&bytes).expect("optional picture data must not block opening"); + let sh = wb.sheet(0).unwrap(); + assert!(sh.cell_pictures.is_empty()); + assert_eq!(sh.value(at("A1")), Value::Error(CellError::Value)); + assert_eq!(sh.value(at("C3")), Value::Number(42.0)); + assert!(report.warnings.iter().any(|w| w.contains("cell-picture data could not be read")), "{:?}", report.warnings); + } +} diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index 073dd36d0..32d67052d 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -43,6 +43,12 @@ impl Rels { self.items.push((id.clone(), format!("{NS_REL}/{kind}"), target.to_string(), true)); id } + /// Adds a Microsoft extension relationship with its complete namespace URI. + pub fn add_uri(&mut self, ty: &str, target: &str) -> String { + let id = format!("rId{}", self.items.len() + 1); + self.items.push((id.clone(), ty.to_string(), target.to_string(), false)); + id + } pub fn is_empty(&self) -> bool { self.items.is_empty() } @@ -97,6 +103,7 @@ pub struct Out { pub table_id: u32, pub dynamic_arrays: bool, pub needs_calc: bool, + pub cell_pictures: crate::richdata::PicturesOut, } impl Out { @@ -130,6 +137,9 @@ pub fn write_xlsx(wb: &Workbook) -> Result, IoError> { if wb.sheets.is_empty() { return Err(IoError::Format("a workbook needs at least one sheet".into())); } + if wb.sheets.iter().any(|sheet| sheet.cell_pictures.keys().any(|c| sheet.cell(*c).is_some_and(|cell| cell.formula.is_some()))) { + return Err(IoError::Format("a static cell picture cannot also contain a formula".into())); + } let mut out = Out { parts: vec![], overrides: vec![], @@ -144,6 +154,7 @@ pub fn write_xlsx(wb: &Workbook) -> Result, IoError> { table_id: 0, dynamic_arrays: false, needs_calc: false, + cell_pictures: Default::default(), }; out.default_type("rels", "application/vnd.openxmlformats-package.relationships+xml"); out.default_type("xml", "application/xml"); @@ -265,7 +276,9 @@ pub fn write_xlsx(wb: &Workbook) -> Result, IoError> { sst.push_str(""); out.part("xl/sharedStrings.xml", Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"), sst.into_bytes()); wb_rels.add("sharedStrings", "sharedStrings.xml"); - if out.dynamic_arrays { + if !out.cell_pictures.is_empty() { + crate::richdata::write(&mut out, &mut wb_rels)?; + } else if out.dynamic_arrays { out.part( "xl/metadata.xml", Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheetMetadata+xml"), diff --git a/docs/cell-pictures.md b/docs/cell-pictures.md new file mode 100644 index 000000000..586615422 --- /dev/null +++ b/docs/cell-pictures.md @@ -0,0 +1,31 @@ +# Pictures inside cells + +Choose **Insert → Pictures → Place in Cell**, select a PNG or JPEG file, and insert it into the +active cell. The picture replaces that cell's contents while keeping its formatting. Undo +restores the previous contents. + +The picture fits inside the cell without stretching. It follows row and column resizing, +sorting, filtering, insertion/deletion and GridCraft's internal copy/cut/paste. Clearing +contents removes it; clearing or pasting only formats keeps it. Save as XLSX to preserve the +embedded image and its alt text, or export to PDF for a rendered copy. + +**Place over Cells** retains the existing floating-picture behavior. + +The command API accepts the same placement: + +```json +{"command":"insert.picture","params":{"path":"picture.png","placement":"cell","at":"B2","alt":"Product photo"}} +``` + +This initial feature handles static pictures. Pasting an image from the operating system's +clipboard, converting between floating and cell pictures, and the `IMAGE()` function remain +separate work. Formula references and arithmetic do not carry pictures: the calculation +engine uses a `#VALUE!` fallback, including when a picture is referenced from another cell. +Text-only exports also use that fallback. The picture cell still counts as occupied and +blocks a formula's spill range. + +XLSX stores these pictures as rich values. Readers without support for that format may show +`#VALUE!` instead of the image. GridCraft reads embedded PNG/JPEG rich-value images; unsupported +rich content, oversized or damaged picture metadata, and broken image references produce import +warnings while the workbook opens with scalar fallbacks. It does not fetch linked +images from the internet. diff --git a/docs/decisions.md b/docs/decisions.md new file mode 100644 index 000000000..87bacd0b8 --- /dev/null +++ b/docs/decisions.md @@ -0,0 +1,28 @@ +# Implementation decisions + +## Static cell pictures + +Store immutable picture payloads in `Sheet::cell_pictures`, a sparse map keyed by `CellRef`, +shared through `Arc`. Ordinary cells keep their original size. Content replacement removes the +map entry; copy, fill, sort and structural edits explicitly transport it, and undo shares bytes. +Serialize the map as address/payload pairs so JSON does not require struct-valued object keys. +Keep the scalar `#VALUE!` fallback used by XLSX consumers that cannot interpret rich images. +This first implementation does not extend formula evaluation to rich values or implement +`IMAGE()`; references and text-only exports use the fallback. + +Write picture value metadata and existing dynamic-array cell metadata into the same workbook +metadata part, with independent index tables. Resolve input parts through their relationships. +Support embedded PNG/JPEG files without fetching external resources. See +[cell pictures](cell-pictures.md) for the supported user workflow and current limits. + +Picture metadata is optional during XLSX import: part-size limits, record limits and relationship +read errors produce warnings and retain scalar cell fallbacks. The generic package size limits +remain unchanged. Rebased on main before revision; ODS/XLSB import PRs are still unmerged, and +`Package::read_limited` uses the same implementation as the incoming XLSB PR #101 to avoid +duplicate helpers when that work lands. + +The UI cache uses a 64 MiB texture budget with at most 4,096 sparse entries. Images used in the +current or preceding frame are protected from eviction. If the visible set exceeds the budget, +new images show the existing placeholder until capacity is available; header-based admission +avoids repeatedly decoding overflow images. More than 128 small visible pictures now keep stable +texture handles instead of cycling through the cache every frame.