Merge pull request #105 from harrisonoconnorhover/codex/cell-pictures

Add static pictures inside cells with XLSX roundtrip

Rebased onto main (conflicts with #83/#98 and main resolved by maintainer); the contributor's branch does not allow maintainer edits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-10 15:02:44 -04:00
co-authored by Claude Opus 5.5
35 changed files with 1638 additions and 85 deletions
+1
View File
@@ -86,6 +86,7 @@
PivotTables and print settings round-trip. CSV and TSV too.
[XLSB worksheet data can be imported](docs/xlsb-import.md), using saved values instead of formulas.
[ODS worksheet data can be imported](docs/ods-import.md), using saved values instead of formulas.
[Static pictures can live inside cells](docs/cell-pictures.md), fitting their size and moving with the data.
- **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,
+12
View File
@@ -101,6 +101,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 {
copy_html: Some(Box::new(copy_html)),
open_async: Some(Box::new(move || {
@@ -119,6 +120,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}");
+6
View File
@@ -131,6 +131,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()
+21 -7
View File
@@ -265,6 +265,7 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec<SortKey>, by_co
// Move whole lines (cells with formulas adjusted for relative references).
let lanes: Vec<u32> = if by_cols { (body.start.row..=body.end.row).collect() } else { (body.start.col..=body.end.col).collect() };
let mut moved: Vec<(CellRef, Option<Cell>)> = 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<SortKey>, 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));
}
}
@@ -313,12 +317,13 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec<SortKey>, 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 {
@@ -684,21 +689,30 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
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<Vec<Option<Cell>>> =
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));
}
@@ -749,9 +763,9 @@ fn text_to_columns(s: &mut Session, p: &Json) -> Result<Json> {
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));
@@ -957,7 +971,7 @@ fn circle_invalid(s: &mut Session, _: &Json) -> Result<Json> {
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());
}
+34 -17
View File
@@ -213,16 +213,16 @@ fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
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<Json> {
};
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<Json> {
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);
}
}
@@ -675,9 +675,9 @@ fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result<Json> {
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);
@@ -790,9 +790,12 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
*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);
}
@@ -811,10 +814,13 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
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<std::sync::Arc<Formula>> {
let f = cell.formula.as_ref()?;
@@ -834,10 +840,12 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
};
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);
@@ -849,6 +857,7 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
};
}
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 {
@@ -857,6 +866,7 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
}
}
What::Values | What::ValuesAndNumberFormats => {
new_picture = src_picture.clone();
new.formula = None;
new.value = src_val.clone();
if what == What::ValuesAndNumberFormats {
@@ -872,6 +882,10 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
&& 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())
@@ -909,9 +923,12 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
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);
}
@@ -1004,12 +1021,12 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
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;
@@ -1229,7 +1246,7 @@ fn replace(s: &mut Session, p: &Json) -> Result<Json> {
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;
@@ -1247,9 +1264,9 @@ fn replace(s: &mut Session, p: &Json) -> Result<Json> {
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);
+1 -1
View File
@@ -845,7 +845,7 @@ fn checkbox(s: &mut Session, p: &Json) -> Result<Json> {
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);
}
}
+1 -1
View File
@@ -585,7 +585,7 @@ fn merge(s: &mut Session, p: &Json, how: &str) -> Result<Json> {
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));
}
+65 -7
View File
@@ -96,7 +96,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Picture",
["Insert", "Illustrations"],
None,
"{path? | base64?: \"...\", mime?, at?: \"B2\", width?, height?, alt?, mode?: moveOnly|moveAndSize|absolute}",
"{path? | base64?: \"...\", mime?, at?: \"B2\", width?, height?, alt?, mode?: moveOnly|moveAndSize|absolute, placement?: cell|overCells}",
has_doc,
insert_picture
),
@@ -230,6 +230,13 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
} 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)));
}
@@ -314,7 +321,7 @@ fn table_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
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);
@@ -343,12 +350,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);
}
}
}
@@ -746,14 +752,39 @@ fn insert_sparkline(s: &mut Session, p: &Json) -> Result<Json> {
}
fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
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"));
}
@@ -787,6 +818,33 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// 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<Json> {
let kind = match str_param(p, "kind").unwrap_or("rectangle") {
"roundedRectangle" => ShapeKind::RoundedRectangle,
@@ -955,7 +1013,7 @@ fn insert_link(s: &mut Session, p: &Json) -> Result<Json> {
});
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);
}
+3 -2
View File
@@ -125,10 +125,11 @@ fn cell_get(s: &mut Session, p: &Json) -> Result<Json> {
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,
+31 -1
View File
@@ -847,11 +847,41 @@ fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Option<crate::
if w <= 0.0 || h <= 0.0 {
continue;
}
draw_text(pg, b, *c, l, (x, y, w, h), merged, &merges, (bx, bx + bw));
if let Some(picture) = sh.cell_pictures.get(c) {
draw_cell_picture(pg, &picture.data, (x, y, w, h), b.k);
} else {
draw_text(pg, b, *c, l, (x, y, w, h), merged, &merges, (bx, bx + bw));
}
}
pg.restore();
}
/// Cell pictures fit the current cell (or merged-cell) rectangle without changing aspect ratio.
fn draw_cell_picture(pg: &mut Page, data: &[u8], rect: (f32, f32, f32, f32), k: f32) {
let (x, y, w, h) = rect;
let pad = 2.0 * k;
let (w, h) = ((w - 2.0 * pad).max(0.0), (h - 2.0 * pad).max(0.0));
if w <= 0.0 || h <= 0.0 || data.len() > 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))
+6 -2
View File
@@ -187,6 +187,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();
// Floating objects follow their anchor lines per their [`AnchorMode`]. Corners were snapshotted
@@ -304,7 +306,7 @@ fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
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)),
@@ -362,7 +364,7 @@ fn delete_cells(s: &mut Session, p: &Json) -> Result<Json> {
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 {
@@ -705,6 +707,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() {
+10 -10
View File
@@ -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<Json> {
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<Json> {
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<Json> {
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<Json> {
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<Json> {
let shm = cx.sheet_mut(dsi)?;
let old: Vec<CellRef> = 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<Json> {
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);
}
+10 -4
View File
@@ -45,6 +45,7 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
let lanes: Vec<u32> = 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<Cell>)> = Vec::new();
let mut pictures = Vec::new();
for lane in lanes {
let src_cells: Vec<(CellRef, Option<Cell>)> = (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<usize> {
}
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);
+4 -4
View File
@@ -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;
}
+180
View File
@@ -1210,3 +1210,183 @@ fn set_anchor_mode_rejects_unknown_kind() {
s.execute("object.setAnchorMode", json!({"kind": "image", "id": id, "mode": "moveOnly"})).unwrap();
assert_eq!(mode(&s), AnchorMode::MoveOnly);
}
fn original_cell_picture() -> Vec<u8> {
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<std::sync::Arc<gridcraft_model::CellPicture>> {
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));
}
}
}
+10
View File
@@ -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<u8>,
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.
+1 -1
View File
@@ -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;
+42 -2
View File
@@ -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<CellRef, Arc<CellPicture>>,
/// Values spilled by dynamic-array formulas (maintained by calc), keyed by cell.
#[serde(skip)]
pub spill: BTreeMap<CellRef, Value>,
@@ -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<Cell> {
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<CellPicture>) {
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<S: Serializer>(map: &BTreeMap<CellRef, Arc<CellPicture>>, serializer: S) -> Result<S::Ok, S::Error> {
map.iter().collect::<Vec<_>>().serialize(serializer)
}
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<BTreeMap<CellRef, Arc<CellPicture>>, D::Error> {
Ok(Vec::<(CellRef, Arc<CellPicture>)>::deserialize(deserializer)?.into_iter().collect())
}
}
+183
View File
@@ -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<usize, Entry>,
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<CellPicture>,
texture: Option<TextureHandle>,
bytes: usize,
used: u64,
}
impl Cache {
fn texture(&mut self, ctx: &egui::Context, picture: &Arc<CellPicture>) -> Option<TextureHandle> {
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<egui::ColorImage> {
let (format, _) = dimensions(picture)?;
decode_format(picture, format)
}
fn decode_format(picture: &CellPicture, format: image::ImageFormat) -> Option<egui::ColorImage> {
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<Rect> {
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<CellPicture>) {
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::<Arc<Mutex<Cache>>>(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<Arc<CellPicture>, 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<dyn std::error::Error>> {
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::<Result<_, _>>()?;
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::<Result<_, _>>()?;
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(())
}
}
+73 -16
View File
@@ -31,6 +31,8 @@ pub struct Dialog {
pub search: String,
pub list_index: usize,
pub result: Option<Json>,
picture_inbox: Option<crate::Inbox>,
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")})),
@@ -644,14 +661,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),
@@ -690,20 +708,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<String> = app
@@ -817,6 +870,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) {
+3
View File
@@ -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();
+4
View File
@@ -545,6 +545,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
+21 -4
View File
@@ -7,6 +7,7 @@
#![forbid(unsafe_code)]
mod border_preview;
mod cell_pictures;
pub mod chartview;
pub mod control;
pub mod credits;
@@ -105,6 +106,11 @@ pub struct SheetView {
pub struct Services {
pub pick_open: Option<Box<dyn Fn() -> Option<String>>>,
pub pick_save: Option<Box<dyn Fn(&str) -> Option<String>>>,
/// Native picture picker (PNG/JPEG), separate from workbook opening.
pub pick_picture: Option<Box<dyn Fn() -> Option<String>>>,
/// 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<Box<dyn Fn(Inbox)>>,
pub open_url: Option<Box<dyn Fn(&str)>>,
/// Publish HTML and its plain-text alternative together. `Ok` means the host owns the
/// write (including any asynchronous fallback); an immediate error uses egui's text path.
@@ -421,11 +427,22 @@ 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);
// 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 locale = self.ui.language.formula_locale();
let current = sh
.cell(at)
.map(|c| if c.formula.is_some() { formula_locale::display(&c.input_text(), locale, &d.wb, d.wb.active_sheet) } else { c.input_text() })
.unwrap_or_default();
let current = if sh.cell_pictures.contains_key(&at) {
String::new()
} else {
sh.cell(at)
.map(
|c| if c.formula.is_some() { formula_locale::display(&c.input_text(), locale, &d.wb, d.wb.active_sheet) } else { c.input_text() },
)
.unwrap_or_default()
};
let (text, replace) = match text {
Some(t) => (t, true),
None => (current.clone(), false),
+9 -3
View File
@@ -1096,9 +1096,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 [
+156
View File
@@ -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<u8> {
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<Mutex<Vec<Inbox>>> = 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)));
}
+1
View File
@@ -20,6 +20,7 @@ mod ods;
mod package;
mod pivot;
mod read;
mod richdata;
mod sheet_read;
mod sheet_write;
mod styles;
+11 -1
View File
@@ -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<u32, String>,
/// Value metadata blocks, indexed by the worksheet's one-based `vm` attribute.
pub cell_pictures: Vec<Option<Arc<CellPicture>>>,
}
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();
+368
View File
@@ -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<String> {
let mut target: Option<String> = 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<Option<El>, 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<Vec<&'a El>, 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<Vec<Option<Arc<CellPicture>>>, 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<CellPicture>>,
total: &mut usize,
) -> Result<Option<Arc<CellPicture>>, 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::<usize>().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<Arc<CellPicture>>,
indices: HashMap<*const CellPicture, u32>,
}
impl PicturesOut {
pub fn is_empty(&self) -> bool {
self.pictures.is_empty()
}
pub fn index(&mut self, picture: &Arc<CellPicture>) -> 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}<rvData xmlns=\"{NS_RICH}\" count=\"{count}\">");
let mut rel_list = format!("{XML_DECL}<richValueRels xmlns=\"{NS_IMAGE_REL}\" xmlns:r=\"{NS_REL}\">");
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, "<rel r:id=\"{rid}\"/>");
let _ = write!(data, "<rv s=\"0\"><v>{i}</v><v>5</v><v>{}</v></rv>", esc(&picture.alt));
}
data.push_str("</rvData>");
rel_list.push_str("</richValueRels>");
let structures = format!(
"{XML_DECL}<rvStructures xmlns=\"{NS_RICH}\" count=\"1\"><s t=\"_localImage\"><k n=\"_rvRel:LocalImageIdentifier\" t=\"i\"/><k n=\"CalcOrigin\" t=\"i\"/><k n=\"Text\" t=\"s\"/></s></rvStructures>"
);
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}<metadata xmlns=\"{NS_MAIN}\" xmlns:rv=\"{NS_RICH}\" xmlns:xda=\"http://schemas.microsoft.com/office/spreadsheetml/2017/dynamicarray\"><metadataTypes count=\"{type_id}\">"
);
if dynamic {
xml.push_str("<metadataType name=\"XLDAPR\" minSupportedVersion=\"120000\" copy=\"1\" pasteAll=\"1\" pasteValues=\"1\" merge=\"1\" splitFirst=\"1\" rowColShift=\"1\" clearFormats=\"1\" clearComments=\"1\" assign=\"1\" coerce=\"1\" cellMeta=\"1\"/>");
}
xml.push_str("<metadataType name=\"XLRICHVALUE\" minSupportedVersion=\"120000\" copy=\"1\" pasteAll=\"1\" pasteValues=\"1\" merge=\"1\" splitFirst=\"1\" rowColShift=\"1\" clearFormats=\"1\" clearComments=\"1\" assign=\"1\" cellMeta=\"0\"/></metadataTypes>");
if dynamic {
xml.push_str("<futureMetadata name=\"XLDAPR\" count=\"1\"><bk><extLst><ext uri=\"{BDBB8CDC-FA1E-496E-A857-3C3F30C029C3}\"><xda:dynamicArrayProperties fDynamic=\"1\" fCollapsed=\"0\"/></ext></extLst></bk></futureMetadata>");
}
let _ = write!(xml, "<futureMetadata name=\"XLRICHVALUE\" count=\"{count}\">");
for i in 0..count {
let _ = write!(xml, "<bk><extLst><ext uri=\"{RICH_EXT}\"><rv:rvb i=\"{i}\"/></ext></extLst></bk>");
}
xml.push_str("</futureMetadata>");
if dynamic {
xml.push_str("<cellMetadata count=\"1\"><bk><rc t=\"1\" v=\"0\"/></bk></cellMetadata>");
}
let _ = write!(xml, "<valueMetadata count=\"{count}\">");
for i in 0..count {
let _ = write!(xml, "<bk><rc t=\"{type_id}\" v=\"{i}\"/></bk>");
}
xml.push_str("</valueMetadata></metadata>");
xml
}
+18
View File
@@ -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::<usize>().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 };
+8 -1
View File
@@ -251,6 +251,7 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
// sheetData
let mut keys: BTreeSet<CellRef> = 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<u32, Vec<CellRef>> = BTreeMap::new();
@@ -295,7 +296,7 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
let f = cell?.formula.as_ref()?;
(f.array.is_none() && crate::array_formula::needs_array(wb, si, *c, f)).then_some(RangeRef::cell(*c))
});
write_cell(&mut s, *c, cell, ov, ex, array.as_ref(), &sid, out);
write_cell(&mut s, *c, cell, sheet.cell_pictures.get(c), ov, ex, array.as_ref(), &sid, out);
}
s.push_str("</row>");
}
@@ -540,6 +541,7 @@ fn write_cell(
s: &mut String,
c: CellRef,
cell: Option<&Cell>,
picture: Option<&std::sync::Arc<gridcraft_model::CellPicture>>,
ov: Option<&String>,
extra: Option<&Value>,
dynamic: Option<&RangeRef>,
@@ -551,6 +553,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}\"><v>#VALUE!</v></c>");
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()),
+1
View File
@@ -6,6 +6,7 @@ mod fixtures;
mod malformed;
mod ods_tests;
mod pivot_tests;
mod richdata_tests;
mod roundtrip;
mod smooth_roundtrip;
mod xlsb_tests;
+272
View File
@@ -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<u8> {
fn chunk(out: &mut Vec<u8>, 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<u8> {
fn segment(data: &mut Vec<u8>, 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<u8>, 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<u8>)> {
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#"<Relationships><Relationship Id="book" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="pkg/book.xml"/></Relationships>"#,
);
add(
"pkg/book.xml",
r#"<workbook xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Pictures" sheetId="1" r:id="sheet"/></sheets></workbook>"#,
);
let rich =
r#"<Relationship Id="value" Type="http://schemas.microsoft.com/office/2017/06/relationships/rdRichValue" Target="../rich/values.xml"/>"#;
let rest = r#"<Relationship Id="structure" Type="http://schemas.microsoft.com/office/2017/06/relationships/rdRichValueStructure" Target="../rich/structures.xml"/><Relationship Id="images" Type="http://schemas.microsoft.com/office/2022/10/relationships/richValueRel" Target="../rich/images.xml"/>"#;
add(
"pkg/_rels/book.xml.rels",
&format!(
r#"<Relationships><Relationship Id="sheet" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="../sheets/data.xml"/><Relationship Id="metadata" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sheetMetadata" Target="../meta/state.xml"/>{}</Relationships>"#,
if chain { String::new() } else { format!("{rich}{rest}") }
),
);
if chain {
add("meta/_rels/state.xml.rels", &format!("<Relationships>{rich}</Relationships>"));
add("rich/_rels/values.xml.rels", &format!("<Relationships>{rest}</Relationships>"));
}
add(
"sheets/data.xml",
r#"<worksheet><sheetData><row r="1"><c r="A1" t="e" vm="2"><v>#VALUE!</v></c><c r="B1" cm="1"><f t="array" ref="B1:B2">_xlfn.SEQUENCE(2)</f><v>1</v></c></row><row r="2"><c r="B2"><v>2</v></c></row></sheetData></worksheet>"#,
);
add(
"meta/state.xml",
r#"<metadata xmlns:rv="http://schemas.microsoft.com/office/spreadsheetml/2017/richdata"><metadataTypes count="2"><metadataType name="XLDAPR" cellMeta="1"/><metadataType name="XLRICHVALUE"/></metadataTypes><futureMetadata name="XLDAPR" count="1"><bk/></futureMetadata><futureMetadata name="XLRICHVALUE" count="2"><bk><extLst><ext uri="{3E2802C4-A4D2-4D8B-9148-E3BE6C30E623}"><rv:rvb i="1"/></ext></extLst></bk><bk><extLst><ext uri="{3E2802C4-A4D2-4D8B-9148-E3BE6C30E623}"><rv:rvb i="0"/></ext></extLst></bk></futureMetadata><cellMetadata count="1"><bk><rc t="1" v="0"/></bk></cellMetadata><valueMetadata count="2"><bk><rc t="1" v="0"/></bk><bk><rc t="1" v="0"/><rc t="2" v="0"/></bk></valueMetadata></metadata>"#,
);
add(
"rich/values.xml",
r#"<rvData count="2"><rv s="0"><v>unused</v></rv><rv s="1"><v>green &amp; &lt;image&gt;</v><v>1</v><v>5</v></rv></rvData>"#,
);
add(
"rich/structures.xml",
r#"<rvStructures count="2"><s t="unsupported"><k n="value" t="s"/></s><s t="_localImage"><k n="Text" t="s"/><k n="_rvRel:LocalImageIdentifier" t="i"/><k n="CalcOrigin" t="i"/></s></rvStructures>"#,
);
add(
"rich/images.xml",
r#"<richValueRels xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><rel r:id="unused"/><rel r:id="opaqueImageId"/></richValueRels>"#,
);
add(
"rich/_rels/images.xml.rels",
r#"<Relationships><Relationship Id="opaqueImageId" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/original.bin"/></Relationships>"#,
);
parts.push(("media/original.bin".into(), png()));
parts
}
fn zip(parts: &[(String, Vec<u8>)]) -> Vec<u8> {
make_zip(&parts.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect::<Vec<_>>())
}
fn replace(parts: &mut [(String, Vec<u8>)], 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 & <image>")));
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 & <a> Ω\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", "<rc t=\"2\" v=\"0\"/>", "<rc t=\"2\" v=\"99\"/>"),
("meta/state.xml", "<rv:rvb i=\"1\"/>", "<rv:rvb i=\"99\"/>"),
("rich/values.xml", "<rv s=\"1\">", "<rv s=\"99\">"),
("rich/values.xml", "<v>1</v>", "<v>99</v>"),
("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<u8>, path: &str, size: u32) -> Vec<u8> {
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", "</sheetData>", "<row r=\"3\"><c r=\"C3\"><v>42</v></c></row></sheetData>");
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!("<rvData count=\"100001\">{}</rvData>", "<rv/>".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);
}
}
+14 -1
View File
@@ -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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, IoError> {
sst.push_str("</sst>");
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"),
+31
View File
@@ -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.
+27
View File
@@ -20,3 +20,30 @@ The exporter inspects the parsed formula without evaluating it. It distinguishes
Names are resolved using workbook/sheet scope, and structured references distinguish one table-row cell from a column. Literal `INDIRECT` and `INDEX` arguments permit tighter classification; unresolved reference dimensions are conservatively treated as potentially multiple cells. Name/AST traversal is bounded, with array preservation as the fallback. This is an export classifier, not a claim of exact Excel metadata parity for every function or user-defined function. It stays within the format crate's existing dependencies and does not change calculation or import behavior.
This is a writer policy; interpreting implicit intersection in imported legacy formulas remains separate work. See Microsoft's [Formula vs. Formula2 documentation](https://learn.microsoft.com/en-us/office/vba/excel/concepts/cells-and-ranges/range-formula-vs-formula2).
## 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.