Support static pictures inside cells with XLSX roundtrip (#61)

This commit is contained in:
Harrison O'Connor-Hoover
2026-10-10 12:43:27 -04:00
parent c5bf34d5d7
commit 9dc3aaf9fa
39 changed files with 1659 additions and 86 deletions
+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?}",
"{path? | base64?: \"...\", mime?, at?: \"B2\", width?, height?, alt?, placement?: cell|overCells}",
has_doc,
insert_picture
),
@@ -220,6 +220,13 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<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)));
}
@@ -304,7 +311,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);
@@ -333,12 +340,11 @@ fn write_totals(sh: &mut Sheet, ti: usize) {
let c = CellRef::new(row, t.range.start.col + i as u32);
if let Some(code) = col.totals.subtotal_code() {
let f = format!("=SUBTOTAL({code},{}[{}])", t.name, col.name);
sh.cells
.set(c, Cell { formula: Some(std::sync::Arc::new(Formula::new(&f))), value: gridcraft_core::Value::Empty, style: sh.style_id(c) });
sh.set_cell(c, Cell { formula: Some(std::sync::Arc::new(Formula::new(&f))), value: gridcraft_core::Value::Empty, style: sh.style_id(c) });
} else if let Some(l) = &col.totals_label {
sh.set_value(c, gridcraft_core::Value::text(l.as_str()));
} else {
sh.cells.remove(c);
sh.remove_cell(c);
}
}
}
@@ -696,14 +702,39 @@ fn insert_sparkline(s: &mut Session, p: &Json) -> Result<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"));
}
@@ -735,6 +766,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,
@@ -846,7 +904,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);
}