mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 10:46:50 -06:00
Support static pictures inside cells with XLSX roundtrip (#61)
This commit is contained in:
@@ -84,6 +84,7 @@
|
||||
- **Compatible.** XLSX is the native format: styles, themes, formulas (including dynamic arrays),
|
||||
tables, conditional formats, validation, comments, hyperlinks, charts, pictures, sparklines,
|
||||
PivotTables and print settings round-trip. CSV and TSV too.
|
||||
[Static pictures can live inside cells](docs/cell-pictures.md), fitting their size and moving with the data.
|
||||
- **Capable.** A dependency-graph calculation engine with dynamic arrays and spilling,
|
||||
500+ worksheet functions plus LET, LAMBDA, MAP, REDUCE, SCAN, BYROW, BYCOL and MAKEARRAY,
|
||||
structured table references, Excel's full number-format language, sort and AutoFilter,
|
||||
|
||||
@@ -94,6 +94,8 @@ by open equivalents or left out, not cloned.
|
||||
- PivotTables: Insert/Recommended PivotTables, field list pane, compact/outline/tabular layouts,
|
||||
date grouping, sort, filters, show-values-as, refresh, and XLSX round-trip.
|
||||
- Page layout & print: Page Break Preview, print settings, PDF export and printing.
|
||||
- Static in-cell PNG/JPEG pictures: file insertion, aspect-preserving fit, cell operations and
|
||||
XLSX rich-value read/write. Clipboard images, floating/cell conversion and `IMAGE()` remain open.
|
||||
- What-if: Goal Seek, scenarios, data tables. Spelling (system word list) and thesaurus.
|
||||
- Draw: pen, highlighter, eraser, ink strokes and Ink to Shape. Task panes: Comments (threaded
|
||||
replies, resolve), Watch Window, Selection, Format Chart.
|
||||
|
||||
@@ -147,6 +147,14 @@ impl Args {
|
||||
fn exec(s: &mut Session, id: &str, params: Value) -> Result<Value, String> {
|
||||
let r = s.execute(id, params).map_err(|e| e.to_string());
|
||||
s.take_ui_requests();
|
||||
if id == "file.open"
|
||||
&& let Ok(value) = &r
|
||||
&& let Some(warnings) = value.get("warnings").and_then(Value::as_array)
|
||||
{
|
||||
for warning in warnings.iter().filter_map(Value::as_str) {
|
||||
eprintln!("Warning: {warning}");
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ impl eframe::App for WebShell {
|
||||
|
||||
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
let open_inbox = inbox.clone();
|
||||
let picture_ctx = ctx.clone();
|
||||
Services {
|
||||
open_async: Some(Box::new(move || {
|
||||
let inbox = open_inbox.clone();
|
||||
@@ -107,6 +108,17 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
ctx.request_repaint();
|
||||
});
|
||||
})),
|
||||
pick_picture_async: Some(Box::new(move |inbox| {
|
||||
let ctx = picture_ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Some(file) = rfd::AsyncFileDialog::new().add_filter("Pictures (PNG, JPEG)", &["png", "jpg", "jpeg"]).pick_file().await else {
|
||||
return;
|
||||
};
|
||||
let bytes = file.read().await;
|
||||
inbox.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push((file.file_name(), bytes));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
})),
|
||||
download: Some(Box::new(|name: &str, bytes: &[u8]| {
|
||||
if let Err(e) = download(name, bytes) {
|
||||
log::error!("download of {name} failed: {e}");
|
||||
|
||||
@@ -116,6 +116,12 @@ fn services() -> Services {
|
||||
.pick_file()
|
||||
.and_then(|p| p.to_str().map(str::to_string))
|
||||
})),
|
||||
pick_picture: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter("Pictures (PNG, JPEG)", &["png", "jpg", "jpeg"])
|
||||
.pick_file()
|
||||
.and_then(|p| p.to_str().map(str::to_string))
|
||||
})),
|
||||
pick_save: Some(Box::new(|suggested: &str| {
|
||||
let stem = std::path::Path::new(suggested).file_stem().and_then(|s| s.to_str()).unwrap_or("Book1").to_string();
|
||||
rfd::FileDialog::new()
|
||||
@@ -220,6 +226,7 @@ fn main() -> eframe::Result<()> {
|
||||
session.new_workbook();
|
||||
}
|
||||
let mut app = SheetApp::new(session, services());
|
||||
app.after_engine();
|
||||
let control_port = control_port;
|
||||
load_prefs(&mut app);
|
||||
let mut viewport = egui::ViewportBuilder::default()
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -286,12 +290,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 {
|
||||
@@ -600,21 +605,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));
|
||||
}
|
||||
@@ -665,9 +679,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));
|
||||
@@ -873,7 +887,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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -668,9 +668,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);
|
||||
@@ -783,9 +783,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);
|
||||
}
|
||||
@@ -804,10 +807,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()?;
|
||||
@@ -827,10 +833,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);
|
||||
@@ -842,6 +850,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 {
|
||||
@@ -850,6 +859,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 {
|
||||
@@ -865,6 +875,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())
|
||||
@@ -902,9 +916,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);
|
||||
}
|
||||
@@ -997,12 +1014,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;
|
||||
@@ -1222,7 +1239,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;
|
||||
@@ -1240,9 +1257,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);
|
||||
|
||||
@@ -840,7 +840,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,13 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
s.close_document(0);
|
||||
}
|
||||
let i = s.add_document(DocState::new(wb, path, title));
|
||||
if !warnings.is_empty() {
|
||||
let mut message = warnings.iter().take(5).cloned().collect::<Vec<_>>().join("\n\n");
|
||||
if warnings.len() > 5 {
|
||||
message.push_str(&format!("\n\n{} additional import warnings.", warnings.len() - 5));
|
||||
}
|
||||
s.ui_requests.push(crate::UiRequest::Message(message));
|
||||
}
|
||||
let d = s.doc()?;
|
||||
Ok(
|
||||
json!({"index": i, "title": d.display_title(), "sheets": d.wb.sheets.iter().map(|s| s.name.clone()).collect::<Vec<_>>(), "warnings": warnings}),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -137,6 +137,8 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
|
||||
None => sh.autofilter = None,
|
||||
}
|
||||
}
|
||||
sh.cell_pictures =
|
||||
std::mem::take(&mut sh.cell_pictures).into_iter().filter_map(|(c, v)| map_cell(c).filter(CellRef::is_valid).map(|c| (c, v))).collect();
|
||||
sh.comments = std::mem::take(&mut sh.comments).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect();
|
||||
sh.hyperlinks = std::mem::take(&mut sh.hyperlinks).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect();
|
||||
for ch in sh.charts.iter_mut() {
|
||||
@@ -247,7 +249,7 @@ fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<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)),
|
||||
@@ -305,7 +307,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 {
|
||||
@@ -648,6 +650,8 @@ fn shift_cell_features(sh: &mut Sheet, e: &Edit) {
|
||||
None => sh.autofilter = None,
|
||||
}
|
||||
}
|
||||
sh.cell_pictures =
|
||||
std::mem::take(&mut sh.cell_pictures).into_iter().filter_map(|(c, v)| map_cell(c).filter(CellRef::is_valid).map(|c| (c, v))).collect();
|
||||
sh.comments = std::mem::take(&mut sh.comments).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect();
|
||||
sh.hyperlinks = std::mem::take(&mut sh.hyperlinks).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect();
|
||||
for sp in sh.sparklines.iter_mut() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -619,3 +619,183 @@ fn ink_strokes_and_ink_to_shape() {
|
||||
assert_eq!(s.execute("draw.inkToShape", json!({})).unwrap()["kind"], "Line");
|
||||
assert!(s.execute("draw.stroke", json!({"points": [[1.0, 1.0]]})).is_err());
|
||||
}
|
||||
|
||||
fn original_cell_picture() -> Vec<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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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")})),
|
||||
@@ -641,14 +658,15 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
"insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" => 520.0,
|
||||
_ => 380.0,
|
||||
};
|
||||
egui::Window::new(d.title.clone())
|
||||
let window = egui::Window::new(d.title.clone())
|
||||
.id(egui::Id::new("dialog").with(&d.name))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.default_width(width)
|
||||
.anchor(egui::Align2::CENTER_CENTER, vec2(0.0, -40.0))
|
||||
.open(&mut win_open)
|
||||
.show(ctx, |ui| {
|
||||
.open(&mut win_open);
|
||||
let window = if d.name == "insertPicture" { window.default_height(240.0) } else { window };
|
||||
window.show(ctx, |ui| {
|
||||
ui.set_min_width(width - 20.0);
|
||||
match d.name.as_str() {
|
||||
"formatCells" => format_cells(app, ui, &mut d, &mut confirm),
|
||||
@@ -687,20 +705,55 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
});
|
||||
}
|
||||
"insertPicture" => {
|
||||
ui.label("Picture file path (PNG or JPEG):");
|
||||
let mut path = d.values.get("path").and_then(Json::as_str).unwrap_or("").to_string();
|
||||
ui.text_edit_singleline(&mut path);
|
||||
d.values.insert("path".into(), json!(path));
|
||||
if let Some(pick) = &app.services.pick_open
|
||||
&& ui.button("Browse…").clicked()
|
||||
&& let Some(p) = pick()
|
||||
{
|
||||
d.values.insert("path".into(), json!(p));
|
||||
// A private inbox dies with this dialog; late browser results
|
||||
// cannot affect a later picture choice or another workbook.
|
||||
let arrived = d.picture_inbox.as_ref().and_then(|inbox| {
|
||||
inbox.lock().unwrap_or_else(std::sync::PoisonError::into_inner).pop()
|
||||
});
|
||||
if let Some((name, bytes)) = arrived {
|
||||
d.picture_inbox = None;
|
||||
if bytes.len() > 16 * 1024 * 1024 {
|
||||
app.message = Some((d.title.clone(), "Choose a PNG or JPEG no larger than 16 MB.".into()));
|
||||
} else {
|
||||
d.values.remove("path");
|
||||
d.values.insert("alt".into(), json!(name));
|
||||
d.values.insert("base64".into(), json!(gridcraft_engine::io::base64_encode(&bytes)));
|
||||
}
|
||||
}
|
||||
if app.services.pick_picture_async.is_some() {
|
||||
let name = d.values.get("alt").and_then(Json::as_str).unwrap_or("No picture selected");
|
||||
ui.label(name);
|
||||
} else {
|
||||
ui.label("Picture file path (PNG or JPEG):");
|
||||
let mut path = d.values.get("path").and_then(Json::as_str).unwrap_or("").to_string();
|
||||
if ui.text_edit_singleline(&mut path).changed() {
|
||||
d.values.remove("base64");
|
||||
d.values.insert("path".into(), json!(path));
|
||||
}
|
||||
}
|
||||
if (app.services.pick_picture.is_some() || app.services.pick_picture_async.is_some()) && ui.button("Browse…").clicked() {
|
||||
if let Some(start) = &app.services.pick_picture_async {
|
||||
let inbox = crate::Inbox::default();
|
||||
start(inbox.clone());
|
||||
d.picture_inbox = Some(inbox);
|
||||
} else if let Some(pick) = &app.services.pick_picture
|
||||
&& let Some(path) = pick()
|
||||
{
|
||||
d.values.remove("base64");
|
||||
let alt = std::path::Path::new(&path).file_name().and_then(|name| name.to_str()).unwrap_or("");
|
||||
d.values.insert("alt".into(), json!(alt));
|
||||
d.values.insert("path".into(), json!(path));
|
||||
}
|
||||
}
|
||||
ui.label("Description (optional):");
|
||||
let mut alt = d.values.get("alt").and_then(Json::as_str).unwrap_or("").to_string();
|
||||
if ui.text_edit_singleline(&mut alt).changed() {
|
||||
d.values.insert("alt".into(), json!(alt));
|
||||
}
|
||||
if d.values.get("placement").and_then(Json::as_str) == Some("cell") {
|
||||
ui.label(egui::RichText::new("Fits inside the selected cell and moves with its content.").small());
|
||||
}
|
||||
ok_cancel(ui, &mut confirm, &mut open);
|
||||
if confirm {
|
||||
app.run_or_alert("insert.picture", json!({"path": d.values.get("path")}));
|
||||
}
|
||||
}
|
||||
"pickList" => {
|
||||
let items: Vec<String> = app
|
||||
@@ -814,6 +867,10 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
if ctx.input(|i| i.key_pressed(Key::Escape)) || !win_open {
|
||||
open = false;
|
||||
}
|
||||
if confirm && d.name == "insertPicture" && d.picture_target != app.view_key() {
|
||||
app.message = Some((d.title.clone(), "Return to the original worksheet to insert this picture, or cancel and choose another cell.".into()));
|
||||
confirm = false;
|
||||
}
|
||||
if confirm && let Some(cmd) = d.command {
|
||||
let params = build_params(&d);
|
||||
match app.run(cmd, params) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -544,6 +544,10 @@ fn paint_quadrant(p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet,
|
||||
// Text and per-cell decorations.
|
||||
for (c, rect, lk, cfl) in &looks {
|
||||
let c = *c;
|
||||
if let Some(picture) = sh.cell_pictures.get(&c) {
|
||||
crate::cell_pictures::paint(p, *rect, picture);
|
||||
continue;
|
||||
}
|
||||
let v = sh.value(c);
|
||||
if let Some(l) = cfl
|
||||
&& let Some((frac, rgb, gradient)) = l.bar
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod border_preview;
|
||||
mod cell_pictures;
|
||||
pub mod chartview;
|
||||
pub mod control;
|
||||
pub mod credits;
|
||||
@@ -94,6 +95,11 @@ pub struct SheetView {
|
||||
pub struct Services {
|
||||
pub pick_open: Option<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)>>,
|
||||
/// Web: deliver a file to the user (name, bytes).
|
||||
pub download: Option<Box<dyn Fn(&str, &[u8])>>,
|
||||
@@ -318,7 +324,13 @@ impl SheetApp {
|
||||
let Some(d) = self.session.active() else { return };
|
||||
let Some(sh) = d.wb.active() else { return };
|
||||
let at = sh.merge_at(d.selection.active).map(|m| m.start).unwrap_or(d.selection.active);
|
||||
let current = sh.cell(at).map(|c| c.input_text()).unwrap_or_default();
|
||||
// A picture has no editable scalar text. Selecting it, F2, or clicking
|
||||
// the formula bar must not turn its fallback value into a text edit.
|
||||
// Typing still starts an intentional replacement.
|
||||
if text.is_none() && sh.cell_pictures.contains_key(&at) {
|
||||
return;
|
||||
}
|
||||
let current = sh.input_text(at);
|
||||
let (text, replace) = match text {
|
||||
Some(t) => (t, true),
|
||||
None => (current.clone(), false),
|
||||
|
||||
@@ -1029,9 +1029,15 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) {
|
||||
}
|
||||
sep(ui);
|
||||
let pic = big_button(ui, Icon::Picture, "Pictures", "Insert a picture", true);
|
||||
if pic.clicked() {
|
||||
app.open_dialog("insertPicture", json!({}));
|
||||
}
|
||||
pic.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), "Pictures"));
|
||||
egui::Popup::menu(&pic).show(|ui| {
|
||||
for (label, placement) in [("Place in Cell", "cell"), ("Place over Cells", "overCells")] {
|
||||
if ui.button(label).clicked() {
|
||||
app.open_dialog("insertPicture", json!({"placement": placement}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
let shapes = big_button(ui, Icon::Shapes, "Shapes", "Shapes", true);
|
||||
egui::Popup::menu(&shapes).show(|ui| {
|
||||
for (label, kind) in [
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -17,6 +17,7 @@ mod fmla;
|
||||
mod package;
|
||||
mod pivot;
|
||||
mod read;
|
||||
mod richdata;
|
||||
mod sheet_read;
|
||||
mod sheet_write;
|
||||
mod styles;
|
||||
|
||||
@@ -58,22 +58,28 @@ impl<'a> Package<'a> {
|
||||
|
||||
/// Reads a part's bytes; `Ok(None)` when it doesn't exist.
|
||||
pub fn read(&mut self, part: &str) -> Result<Option<Vec<u8>>, IoError> {
|
||||
self.read_limited(part, MAX_PART)
|
||||
}
|
||||
|
||||
/// Reads a small metadata part without allowing it to expand to the general part limit.
|
||||
pub fn read_limited(&mut self, part: &str, maximum: u64) -> Result<Option<Vec<u8>>, IoError> {
|
||||
let maximum = maximum.min(MAX_PART);
|
||||
let Some(&idx) = self.names.get(&normalize(part)) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut f = self.zip.by_index(idx).map_err(|e| IoError::Zip(format!("{part}: {e}")))?;
|
||||
if f.size() > MAX_PART {
|
||||
if f.size() > maximum {
|
||||
return Err(IoError::TooLarge(format!("{part} expands to {} bytes", f.size())));
|
||||
}
|
||||
let remaining = MAX_TOTAL.saturating_sub(self.total);
|
||||
let limit = MAX_PART.min(remaining);
|
||||
let limit = maximum.min(remaining);
|
||||
let mut out = Vec::with_capacity(f.size().min(16 * 1024 * 1024) as usize);
|
||||
(&mut f).take(limit + 1).read_to_end(&mut out).map_err(|e| IoError::Zip(format!("{part}: {e}")))?;
|
||||
if out.len() as u64 > limit {
|
||||
return Err(IoError::TooLarge(if limit < MAX_PART {
|
||||
return Err(IoError::TooLarge(if remaining < maximum {
|
||||
"package expands to more than 1 GB".into()
|
||||
} else {
|
||||
format!("{part} expands to more than 512 MB")
|
||||
format!("{part} expands to more than {maximum} bytes")
|
||||
}));
|
||||
}
|
||||
self.total += out.len() as u64;
|
||||
|
||||
+11
-1
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
@@ -291,7 +292,7 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
|
||||
let cell = sheet.cells.get(*c);
|
||||
let ov = overrides.get(c);
|
||||
let ex = extra.get(c);
|
||||
write_cell(&mut s, *c, cell, ov, ex, dynamic.get(c), &sid, out);
|
||||
write_cell(&mut s, *c, cell, sheet.cell_pictures.get(c), ov, ex, dynamic.get(c), &sid, out);
|
||||
}
|
||||
s.push_str("</row>");
|
||||
}
|
||||
@@ -536,6 +537,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>,
|
||||
@@ -547,6 +549,11 @@ fn write_cell(
|
||||
if style != 0 {
|
||||
let _ = write!(s, " s=\"{style}\"");
|
||||
}
|
||||
if let Some(picture) = picture {
|
||||
let vm = out.cell_pictures.index(picture);
|
||||
let _ = write!(s, " t=\"e\" vm=\"{vm}\"><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()),
|
||||
|
||||
@@ -4,6 +4,7 @@ mod csv_tests;
|
||||
mod fixtures;
|
||||
mod malformed;
|
||||
mod pivot_tests;
|
||||
mod richdata_tests;
|
||||
mod roundtrip;
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
@@ -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 & <image></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);
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Implementation decisions
|
||||
|
||||
## Static cell pictures
|
||||
|
||||
Store immutable picture payloads in `Sheet::cell_pictures`, a sparse map keyed by `CellRef`,
|
||||
shared through `Arc`. Ordinary cells keep their original size. Content replacement removes the
|
||||
map entry; copy, fill, sort and structural edits explicitly transport it, and undo shares bytes.
|
||||
Serialize the map as address/payload pairs so JSON does not require struct-valued object keys.
|
||||
Keep the scalar `#VALUE!` fallback used by XLSX consumers that cannot interpret rich images.
|
||||
This first implementation does not extend formula evaluation to rich values or implement
|
||||
`IMAGE()`; references and text-only exports use the fallback.
|
||||
|
||||
Write picture value metadata and existing dynamic-array cell metadata into the same workbook
|
||||
metadata part, with independent index tables. Resolve input parts through their relationships.
|
||||
Support embedded PNG/JPEG files without fetching external resources. See
|
||||
[cell pictures](cell-pictures.md) for the supported user workflow and current limits.
|
||||
|
||||
Picture metadata is optional during XLSX import: part-size limits, record limits and relationship
|
||||
read errors produce warnings and retain scalar cell fallbacks. The generic package size limits
|
||||
remain unchanged. Rebased on main before revision; ODS/XLSB import PRs are still unmerged, and
|
||||
`Package::read_limited` uses the same implementation as the incoming XLSB PR #101 to avoid
|
||||
duplicate helpers when that work lands.
|
||||
|
||||
The UI cache uses a 64 MiB texture budget with at most 4,096 sparse entries. Images used in the
|
||||
current or preceding frame are protected from eviction. If the visible set exceeds the budget,
|
||||
new images show the existing placeholder until capacity is available; header-based admission
|
||||
avoids repeatedly decoding overflow images. More than 128 small visible pictures now keep stable
|
||||
texture handles instead of cycling through the cache every frame.
|
||||
Reference in New Issue
Block a user