From cbc2430f4f03cd2e7798db992d998590922ac9fc Mon Sep 17 00:00:00 2001 From: justinjohn0306 <34035011+justinjohn0306@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:58:53 +0530 Subject: [PATCH] =?UTF-8?q?Objects:=20anchor=20modes=20=E2=80=94=20images/?= =?UTF-8?q?charts/shapes=20follow=20cells=20on=20sort=20(#5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Excel anchors a floating object to the grid in one of three ways, stored in the file as the drawing anchor's `editAs`: "move and size with cells" (twoCellAnchor, the default), "move but don't size with cells" (oneCellAnchor / editAs="oneCell") and "don't move or size with cells" (editAs="absolute"). GridCraft had no such concept: xlsx import dropped `editAs`, export always wrote a plain twoCellAnchor, and sorting/filtering never moved an object — so pictures stayed put when their rows were reordered. Model: add `AnchorMode` on `Anchor` (serde default = MoveAndSize, so existing documents round-trip unchanged). xlsx: read the anchor kind and `editAs` into the mode; write it back out (editAs="oneCell"/"absolute" as Excel does). Engine: sorting now moves objects anchored inside the sorted body with their lines, re-fitting "move and size" objects, keeping "move but don't size" objects' size, and leaving absolute ones pinned. Structural insert/delete honours the same rule (previously it moved every object's cell unconditionally and ignored shapes). New `object.setAnchorMode` command sets an object's mode; `arrange.selectionPane` reports it. Tests: sort moves images with their rows; absolute objects stay put across a sort and a row delete; move-only keeps its size; the mode survives an xlsx round-trip; the drawing fixture parses all three anchor kinds. --- crates/engine/src/cmd/data.rs | 77 +++++++++++++++++++++++++ crates/engine/src/cmd/draw.rs | 10 +++- crates/engine/src/cmd/extra.rs | 6 +- crates/engine/src/cmd/insert.rs | 35 +++++++++++- crates/engine/src/cmd/mod.rs | 24 ++++++++ crates/engine/src/cmd/print.rs | 2 +- crates/engine/src/cmd/sheet.rs | 75 ++++++++++++++++++++++--- crates/engine/src/tests.rs | 90 ++++++++++++++++++++++++++++++ crates/model/src/features.rs | 17 ++++++ crates/xlsx/src/drawing.rs | 50 +++++++++++++---- crates/xlsx/src/tests/fixtures.rs | 10 ++++ crates/xlsx/src/tests/roundtrip.rs | 15 +++-- 12 files changed, 379 insertions(+), 32 deletions(-) diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index 8961d87..43f59f5 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -283,6 +283,33 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec, by_co // Row heights travel with rows. let heights: Vec> = if by_cols { vec![] } else { order.iter().map(|&i| sh.rows.get(&(body.start.row + i)).copied()).collect() }; + // Floating objects anchored inside the sorted body travel with their lines, unless they + // are pinned absolutely ("Don't move or size with cells", `editAs="absolute"`). Reordering + // is the inverse of `order`: `dst_of[src] = dst`. + let dst_of: Vec = { + let mut v = vec![0u32; order.len()]; + for (dst_i, &src_i) in order.iter().enumerate() { + if let Some(slot) = v.get_mut(src_i as usize) { + *slot = dst_i as u32; + } + } + v + }; + // The object's own geometry (its top-left anchor cell, offset and size) is read from the + // pre-sort `sh`; a two-cell object's bottom-right cell is derived from its size here so it + // can be re-fitted to the lines it spans after the sort. + let objs: Vec = + sh.images.iter().map(|o| o.anchor).chain(sh.charts.iter().map(|o| o.anchor)).chain(sh.shapes.iter().map(|o| o.anchor)).collect(); + let descs: Vec<(u32, u32, f32, f32)> = objs + .iter() + .map(|a| { + let x2 = sh.col_left(a.cell.col) + a.dx as f64 + a.width.max(0.0) as f64; + let y2 = sh.row_top(a.cell.row) + a.dy as f64 + a.height.max(0.0) as f64; + let tc = sh.col_at(x2); + let tr = sh.row_at(y2); + (tc, tr, (x2 - sh.col_left(tc)) as f32, (y2 - sh.row_top(tr)) as f32) + }) + .collect(); let shm = cx.sheet_mut(sheet)?; for (c, cell) in &moved { match cell { @@ -305,6 +332,27 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec, by_co } } } + // Re-anchor floating objects now that the row heights are final. `objs` was collected as + // images ++ charts ++ shapes, so the same index maps straight onto each vec. + let (b0, b1) = if by_cols { (body.start.col, body.end.col) } else { (body.start.row, body.end.row) }; + let ni = shm.images.len(); + let nch = shm.charts.len(); + for (i, a) in objs.into_iter().enumerate() { + let na = sorted_anchor(shm, a, descs[i], by_cols, b0, b1, &dst_of); + if na != a { + if i < ni { + if let Some(o) = shm.images.get_mut(i) { + o.anchor = na; + } + } else if i < ni + nch { + if let Some(o) = shm.charts.get_mut(i - ni) { + o.anchor = na; + } + } else if let Some(o) = shm.shapes.get_mut(i - ni - nch) { + o.anchor = na; + } + } + } for (c, _) in moved { cx.changed.push((sheet, c)); } @@ -314,6 +362,35 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec, by_co Ok(json!({"sorted": body.a1()})) } +/// Re-anchor one floating object for a sort. `bottom` is the object's bottom-right line and the +/// offset of that corner inside it, measured before the sort. `map` (via `dst_of`) translates a +/// line index on the moving axis and is the identity outside `b0..=b1`. `sh` is the post-sort +/// grid (row heights already reordered), so re-fitting uses the heights the object lands on. +/// Objects pinned absolutely ("Don't move or size with cells") never move; "move but don't size" +/// keeps its size; "move and size" is re-fitted when its whole extent stays inside the body. +fn sorted_anchor(sh: &Sheet, a: Anchor, bottom: (u32, u32, f32, f32), by_cols: bool, b0: u32, b1: u32, dst_of: &[u32]) -> Anchor { + if a.mode == AnchorMode::Absolute { + return a; + } + let (tc, tr, tdx, tdy) = bottom; + let map = |v: u32| -> u32 { if v >= b0 && v <= b1 { dst_of.get((v - b0) as usize).map_or(v, |&d| b0 + d) } else { v } }; + let (axis_start, axis_end) = if by_cols { (a.cell.col, tc) } else { (a.cell.row, tr) }; + if axis_start < b0 || axis_start > b1 { + return a; + } + let (nr, nc) = if by_cols { (a.cell.row, map(a.cell.col)) } else { (map(a.cell.row), a.cell.col) }; + if a.mode == AnchorMode::MoveAndSize && axis_end >= b0 && axis_end <= b1 { + let (nbr, nbc) = if by_cols { (tr, map(tc)) } else { (map(tr), tc) }; + let x1 = sh.col_left(nc) + a.dx as f64; + let y1 = sh.row_top(nr) + a.dy as f64; + let x2 = sh.col_left(nbc) + tdx as f64; + let y2 = sh.row_top(nbr) + tdy as f64; + Anchor { cell: CellRef::new(nr, nc), dx: a.dx, dy: a.dy, width: (x2 - x1).max(0.0) as f32, height: (y2 - y1).max(0.0) as f32, mode: a.mode } + } else { + Anchor { cell: CellRef::new(nr, nc), ..a } + } +} + // ---------------------------------------------------------------- filter fn toggle_filter(s: &mut Session, p: &Json) -> Result { diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index e1fcd72..7bf0ed3 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -82,7 +82,15 @@ fn stroke(s: &mut Session, p: &Json) -> Result { let shape = Shape { id, kind: ShapeKind::Ink, - anchor: Anchor { cell, dx, dy, width: (maxx - minx).max(1.0) as f32, height: (maxy - miny).max(1.0) as f32 }, + anchor: Anchor { + cell, + dx, + dy, + width: (maxx - minx).max(1.0) as f32, + height: (maxy - miny).max(1.0) as f32, + // Ink is a freehand mark: pinned where it was drawn, it does not follow cells. + mode: gridcraft_model::AnchorMode::Absolute, + }, fill: Color::from_hex(&format!("#{:02X}0000", width.round().clamp(0.0, 255.0) as u8)).unwrap_or_default(), line: Color::from_hex(&color).unwrap_or(Color::rgb(0x1F, 0x5F, 0xC9)), text, diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 5ebc8b8..d936280 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -468,13 +468,13 @@ fn selection_pane(s: &mut Session, _: &Json) -> Result { let sh = d.wb.active().ok_or(EngineError::NoDocument)?; let mut v = Vec::new(); for c in &sh.shapes { - v.push(json!({"kind": "shape", "id": c.id, "name": format!("{:?} {}", c.kind, c.id), "at": c.anchor.cell.a1()})); + v.push(json!({"kind": "shape", "id": c.id, "name": format!("{:?} {}", c.kind, c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)})); } for c in &sh.images { - v.push(json!({"kind": "image", "id": c.id, "name": format!("Picture {}", c.id), "at": c.anchor.cell.a1()})); + v.push(json!({"kind": "image", "id": c.id, "name": format!("Picture {}", c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)})); } for c in &sh.charts { - v.push(json!({"kind": "chart", "id": c.id, "name": format!("Chart {}", c.id), "title": c.title, "at": c.anchor.cell.a1()})); + v.push(json!({"kind": "chart", "id": c.id, "name": format!("Chart {}", c.id), "title": c.title, "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)})); } Ok(Json::Array(v)) } diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index e4b5bc9..7a4d7d7 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -124,6 +124,15 @@ pub fn specs() -> Vec { )), cmd!("object.delete", "Delete Object", [], None, "{kind: chart|image|shape, id}", has_doc, delete_object), cmd!("object.move", "Move Object", [], None, "{kind: chart|image|shape, id, at?: \"C3\", dx?, dy?, width?, height?}", has_doc, move_object), + cmd!( + "object.setAnchorMode", + "Object Properties", + [], + None, + "{kind: chart|image|shape, id, mode: moveAndSize|moveOnly|absolute}", + has_doc, + set_anchor_mode + ), cmd!( "insert.link", "Link", @@ -566,6 +575,7 @@ fn insert_chart(s: &mut Session, p: &Json) -> Result { dy: 0.0, width: f64_param(p, "width").unwrap_or(480.0) as f32, height: f64_param(p, "height").unwrap_or(288.0) as f32, + mode: anchor_mode_param(p).unwrap_or_default(), }, title, series, @@ -723,6 +733,7 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result { dy: 0.0, width: f64_param(p, "width").map(|v| v as f32).unwrap_or(w as f32 * scale), height: f64_param(p, "height").map(|v| v as f32).unwrap_or(h as f32 * scale), + mode: anchor_mode_param(p).unwrap_or_default(), }, data, mime, @@ -764,6 +775,7 @@ fn insert_shape(s: &mut Session, p: &Json) -> Result { dy: 0.0, width: f64_param(p, "width").unwrap_or(144.0) as f32, height: f64_param(p, "height").unwrap_or(if kind == ShapeKind::Line { 0.0 } else { 96.0 }) as f32, + mode: anchor_mode_param(p).unwrap_or_default(), }, fill, line, @@ -822,6 +834,27 @@ fn move_object(s: &mut Session, p: &Json) -> Result { }) } +/// Sets an object's anchor mode (Excel's "Move and size with cells" / "Move but don't size with +/// cells" / "Don't move or size with cells"), so it follows — or ignores — its rows and columns +/// when the sheet is sorted or edited structurally. +fn set_anchor_mode(s: &mut Session, p: &Json) -> Result { + let id = u32_param(p, "id").ok_or_else(|| bad("object.setAnchorMode", "missing `id`"))?; + let kind = str_param(p, "kind").unwrap_or("").to_string(); + let mode = anchor_mode_param(p).ok_or_else(|| bad("object.setAnchorMode", "mode must be moveAndSize, moveOnly or absolute"))?; + let sheet = s.doc()?.wb.active_sheet; + edit(s, |cx| { + let sh = cx.sheet_mut(sheet)?; + let anchor = match kind.as_str() { + "chart" => sh.charts.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor), + "image" => sh.images.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor), + _ => sh.shapes.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor), + }; + let Some(a) = anchor else { return Err(bad("object.setAnchorMode", "no such object")) }; + a.mode = mode; + Ok(json!({"mode": anchor_mode_name(mode)})) + }) +} + // ---------------------------------------------------------------- links & comments fn insert_link(s: &mut Session, p: &Json) -> Result { @@ -1134,7 +1167,7 @@ fn insert_icon(s: &mut Session, p: &Json) -> Result { let shape = Shape { id, kind: ShapeKind::Icon, - anchor: Anchor { cell: at, dx: 4.0, dy: 4.0, width: size, height: size }, + anchor: Anchor { cell: at, dx: 4.0, dy: 4.0, width: size, height: size, mode: anchor_mode_param(p).unwrap_or_default() }, fill: color, line: Color::Auto, text: name, diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 397a05c..c827a6e 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -146,6 +146,30 @@ pub(crate) fn bool_param(p: &Json, key: &str) -> Option { pub(crate) fn u32_param(p: &Json, key: &str) -> Option { p.get(key).and_then(Json::as_u64).map(|v| v.min(u32::MAX as u64) as u32) } + +/// An object anchor mode from Excel's wording or the model's: `"moveAndSize"`/`"twoCell"`, +/// `"moveOnly"`/`"oneCell"`, `"absolute"`/`"don't move or size"` (case-insensitive). +pub(crate) fn anchor_mode_param(p: &Json) -> Option { + parse_anchor_mode(str_param(p, "mode").or_else(|| str_param(p, "anchorMode"))?) +} + +pub(crate) fn parse_anchor_mode(s: &str) -> Option { + match s.to_ascii_lowercase().replace([' ', '-', '_', '\''], "").as_str() { + "moveandsize" | "moveandsizeandcells" | "twocell" => Some(gridcraft_model::AnchorMode::MoveAndSize), + "moveonly" | "movebutdontsize" | "movebutdontsizewithcells" | "onecell" => Some(gridcraft_model::AnchorMode::MoveOnly), + "absolute" | "dontmoveorsize" | "dontmoveorsizewithcells" | "none" => Some(gridcraft_model::AnchorMode::Absolute), + _ => None, + } +} + +/// The wire name of an anchor mode (matches the `mode` param of [`anchor_mode_param`]). +pub(crate) fn anchor_mode_name(m: gridcraft_model::AnchorMode) -> &'static str { + match m { + gridcraft_model::AnchorMode::MoveAndSize => "moveAndSize", + gridcraft_model::AnchorMode::MoveOnly => "moveOnly", + gridcraft_model::AnchorMode::Absolute => "absolute", + } +} pub(crate) fn ok() -> Result { Ok(Json::Null) } diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index a51a2f1..ef83e12 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -1673,7 +1673,7 @@ mod tests { let id = wb.next_object_id(); wb.sheet_mut(0).unwrap().images.push(Image { id, - anchor: Anchor { cell: CellRef::new(5, 5), dx: 0.0, dy: 0.0, width: 40.0, height: 40.0 }, + anchor: Anchor { cell: CellRef::new(5, 5), dx: 0.0, dy: 0.0, width: 40.0, height: 40.0, mode: gridcraft_model::AnchorMode::MoveAndSize }, data: png, mime: "image/png".into(), alt: String::new(), diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 837f080..3ce4b20 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef}; use gridcraft_formula::adjust::{Axis, Edit}; -use gridcraft_model::{Sheet, Visibility}; +use gridcraft_model::{Anchor, AnchorMode, Sheet, Visibility}; use serde_json::{Value as Json, json}; use super::*; @@ -61,6 +61,46 @@ fn lines(s: &Session, p: &Json, axis: Axis) -> Result<(u32, u32)> { Ok((a, count)) } +/// Bottom-right line of an anchor (the `to` marker of a two-cell anchor) and the offset of that +/// corner inside it, in points. Measured against `sh`'s current geometry. +fn anchor_corner(sh: &Sheet, a: &Anchor) -> (u32, u32, f32, f32) { + let x2 = sh.col_left(a.cell.col) + a.dx as f64 + a.width.max(0.0) as f64; + let y2 = sh.row_top(a.cell.row) + a.dy as f64 + a.height.max(0.0) as f64; + let tc = sh.col_at(x2); + let tr = sh.row_at(y2); + (tc, tr, (x2 - sh.col_left(tc)) as f32, (y2 - sh.row_top(tr)) as f32) +} + +/// Repositions one floating object's anchor for a structural insert/delete, honouring its +/// [`AnchorMode`]: absolute objects ("Don't move or size with cells") stay put; "move but don't +/// size" follows its cell and keeps its size; "move and size" is re-fitted to the lines it lands +/// on. `corner` is the pre-edit bottom-right line/offset (from [`anchor_corner`]); a line deleted +/// out from under an edge collapses that edge onto `at`. `sh` is the post-edit grid, used to +/// measure the re-fitted size. +fn shift_anchor(sh: &Sheet, a: Anchor, corner: (u32, u32, f32, f32), axis: Axis, at: u32, map_line: &impl Fn(u32) -> Option) -> Anchor { + if a.mode == AnchorMode::Absolute { + return a; + } + let main = if axis == Axis::Rows { a.cell.row } else { a.cell.col }; + // A deleted anchor line collapses onto the line that takes its place. + let nmain = map_line(main).unwrap_or(at); + let (nr, nc) = if axis == Axis::Rows { (nmain, a.cell.col) } else { (a.cell.row, nmain) }; + if a.mode == AnchorMode::MoveOnly { + return Anchor { cell: CellRef::new(nr, nc), ..a }; + } + // Move and size: re-fit to the lines the object lands on. A far edge whose line was deleted + // collapses onto the near cell, shrinking the object. + let (tc, tr, tdx, tdy) = corner; + let far = if axis == Axis::Rows { tr } else { tc }; + let nfar = map_line(far).unwrap_or(nmain); + let (nbr, nbc) = if axis == Axis::Rows { (nfar, tc) } else { (tr, nfar) }; + let x1 = sh.col_left(nc) + a.dx as f64; + let y1 = sh.row_top(nr) + a.dy as f64; + let xn = sh.col_left(nbc) + tdx as f64; + let yn = sh.row_top(nbr) + tdy as f64; + Anchor { cell: CellRef::new(nr, nc), dx: a.dx, dy: a.dy, width: (xn - x1).max(0.0) as f32, height: (yn - y1).max(0.0) as f32, mode: a.mode } +} + /// Shifts everything that has a position on the sheet for an insert/delete. fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert: bool) { let map_line = |v: u32| -> Option { @@ -94,6 +134,16 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert: Axis::Cols => map_line(c.col).map(|x| CellRef::new(c.row, x)), } }; + // Floating objects: snapshot their bottom-right corners against the pre-shift geometry, so a + // re-fit after the shift measures against the grid the object actually lands on. + let obj_corners: Vec<(u32, u32, f32, f32)> = sh + .charts + .iter() + .map(|o| &o.anchor) + .chain(sh.images.iter().map(|o| &o.anchor)) + .chain(sh.shapes.iter().map(|o| &o.anchor)) + .map(|a| anchor_corner(sh, a)) + .collect(); // Line info. let lines = if axis == Axis::Rows { &mut sh.rows } else { &mut sh.cols }; let old = std::mem::take(lines); @@ -151,15 +201,22 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert: } 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() { - if let Some(c) = map_cell(ch.anchor.cell) { - ch.anchor.cell = c; - } + // Floating objects follow their anchor lines per their [`AnchorMode`]. Corners were snapshotted + // as charts ++ images ++ shapes, matching the three slices below. New anchors are computed + // against the post-shift grid first (the re-fit needs its sizes), then written back. + let anchored = |ui: usize, a: Anchor| -> Anchor { obj_corners.get(ui).map_or(a, |&corner| shift_anchor(sh, a, corner, axis, at, &map_line)) }; + let new_charts: Vec = sh.charts.iter().enumerate().map(|(i, o)| anchored(i, o.anchor)).collect(); + let new_images: Vec = sh.images.iter().enumerate().map(|(i, o)| anchored(new_charts.len() + i, o.anchor)).collect(); + let base = new_charts.len() + new_images.len(); + let new_shapes: Vec = sh.shapes.iter().enumerate().map(|(i, o)| anchored(base + i, o.anchor)).collect(); + for (o, a) in sh.charts.iter_mut().zip(new_charts) { + o.anchor = a; } - for im in sh.images.iter_mut() { - if let Some(c) = map_cell(im.anchor.cell) { - im.anchor.cell = c; - } + for (o, a) in sh.images.iter_mut().zip(new_images) { + o.anchor = a; + } + for (o, a) in sh.shapes.iter_mut().zip(new_shapes) { + o.anchor = a; } for sp in sh.sparklines.iter_mut() { if let Some(c) = map_cell(sp.cell) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f3e36cd..375e37f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -190,3 +190,93 @@ 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()); } + +/// A 2×2 PNG, so objects can be injected into a workbook in tests. +fn tiny_png() -> Vec { + let img = image::RgbaImage::from_raw(2, 2, vec![255, 0, 0, 255, 0, 255, 0, 128, 0, 0, 255, 255, 0, 0, 0, 0]).unwrap(); + let mut png = Vec::new(); + image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + png +} + +fn add_image(s: &mut Session, cell: &str, mode: gridcraft_model::AnchorMode) -> u32 { + let d = s.doc_mut().unwrap(); + let wb = std::sync::Arc::make_mut(&mut d.wb); + let id = wb.next_object_id(); + wb.sheet_mut(0).unwrap().images.push(gridcraft_model::Image { + id, + anchor: gridcraft_model::Anchor { cell: CellRef::parse(cell).unwrap(), dx: 0.0, dy: 0.0, width: 40.0, height: 40.0, mode }, + data: tiny_png(), + mime: "image/png".into(), + alt: String::new(), + }); + id +} + +fn image_cell(s: &Session, id: u32) -> Option { + s.doc().unwrap().wb.active().unwrap().images.iter().find(|i| i.id == id).map(|i| i.anchor.cell) +} + +/// Issue #5: images anchored in cells follow their row when the sheet is sorted. +#[test] +fn sort_moves_anchored_images_with_their_rows() { + use gridcraft_model::AnchorMode; + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Score"], ["b", 2], ["c", 3], ["a", 1]]})).unwrap(); + // One picture per data row, in its own row. + let ib = add_image(&mut s, "A2", AnchorMode::MoveAndSize); + let ic = add_image(&mut s, "A3", AnchorMode::MoveAndSize); + let ia = add_image(&mut s, "A4", AnchorMode::MoveAndSize); + s.execute("selection.set", json!({"range": "A1:B4"})).unwrap(); + s.execute("data.sortAscending", json!({"header": true, "column": "A"})).unwrap(); + // Ascending by name: b, c, a → a, b, c. Each picture lands in its row's new home. + assert_eq!(image_cell(&s, ib), Some(CellRef::parse("A3").unwrap())); + assert_eq!(image_cell(&s, ic), Some(CellRef::parse("A4").unwrap())); + assert_eq!(image_cell(&s, ia), Some(CellRef::parse("A2").unwrap())); +} + +/// An `absolute`-pinned object stays exactly where it was drawn across a sort and a row delete. +#[test] +fn absolute_objects_ignore_sort_and_structural_edits() { + use gridcraft_model::AnchorMode; + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Score"], ["b", 2], ["c", 3], ["a", 1]]})).unwrap(); + let pinned = add_image(&mut s, "A3", AnchorMode::Absolute); + s.execute("selection.set", json!({"range": "A1:B4"})).unwrap(); + s.execute("data.sortAscending", json!({"header": true, "column": "A"})).unwrap(); + assert_eq!(image_cell(&s, pinned), Some(CellRef::parse("A3").unwrap())); + s.execute("home.deleteRows", json!({"rows": "2:2"})).unwrap(); + assert_eq!(image_cell(&s, pinned), Some(CellRef::parse("A3").unwrap())); +} + +/// A "move but don't size" object follows its row across a sort but keeps its size. +#[test] +fn move_only_objects_follow_but_do_not_resize() { + use gridcraft_model::AnchorMode; + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "N"], ["b", 2], ["a", 1]]})).unwrap(); + let id = add_image(&mut s, "A2", AnchorMode::MoveOnly); + s.execute("selection.set", json!({"range": "A1:B3"})).unwrap(); + s.execute("data.sortAscending", json!({"column": "A"})).unwrap(); + // "a" moved to row 2 and "b" to row 3, so the image follows from A2 to A3. + assert_eq!(image_cell(&s, id), Some(CellRef::parse("A3").unwrap())); + let a = s.doc().unwrap().wb.active().unwrap().images.iter().find(|i| i.id == id).unwrap().anchor; + assert_eq!((a.width, a.height), (40.0, 40.0)); +} + +/// `object.setAnchorMode` changes how an object follows its cells. +#[test] +fn set_anchor_mode_changes_following() { + use gridcraft_model::AnchorMode; + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "N"], ["b", 2], ["a", 1]]})).unwrap(); + let id = add_image(&mut s, "A2", AnchorMode::MoveAndSize); + let r = s.execute("object.setAnchorMode", json!({"kind": "image", "id": id, "mode": "dontMoveOrSizeWithCells"})).unwrap(); + assert_eq!(r["mode"], "absolute"); + s.execute("selection.set", json!({"range": "A1:B3"})).unwrap(); + s.execute("data.sortAscending", json!({"column": "A"})).unwrap(); + // Pinned absolute: it stays put even though its row moved. + assert_eq!(image_cell(&s, id), Some(CellRef::parse("A2").unwrap())); + // A bad mode is rejected, not silently ignored. + assert!(s.execute("object.setAnchorMode", json!({"kind": "image", "id": id, "mode": "sideways"})).is_err()); +} diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index cd358a4..0a1945a 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -290,6 +290,20 @@ pub struct Hyperlink { // ---------------------------------------------------------------- drawing objects +/// How a floating object is pinned to the grid when its cells move or resize. Mirrors Excel's +/// Format ▸ Size and Properties ▸ Properties, stored in the file as the anchor's `editAs`: +/// `twoCell` (absent `editAs`), `oneCell` and `absolute`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum AnchorMode { + /// Move and size with cells (Excel's default; ``). + #[default] + MoveAndSize, + /// Move but don't size with cells (`` / ``). + MoveOnly, + /// Don't move or size with cells (`editAs="absolute"` / ``). + Absolute, +} + /// Position of a floating object: top-left cell plus offset, size in points. #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct Anchor { @@ -298,6 +312,9 @@ pub struct Anchor { pub dy: f32, pub width: f32, pub height: f32, + /// How the object follows its anchor cell when rows/columns move (sort, insert, delete). + #[serde(default)] + pub mode: AnchorMode, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] diff --git a/crates/xlsx/src/drawing.rs b/crates/xlsx/src/drawing.rs index e65a21b..c61425e 100644 --- a/crates/xlsx/src/drawing.rs +++ b/crates/xlsx/src/drawing.rs @@ -4,7 +4,7 @@ use std::fmt::Write as _; use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS}; -use gridcraft_model::{Anchor, Color, Image, Shape, ShapeKind, Sheet, Theme}; +use gridcraft_model::{Anchor, AnchorMode, Color, Image, Shape, ShapeKind, Sheet, Theme}; use crate::IoError; use crate::read::Ctx; @@ -40,12 +40,19 @@ fn read_anchor(a: &El, sheet: &Sheet) -> Option { let y1 = sheet.row_top(cell.row) + dy as f64; let x2 = sheet.col_left(to.col) + tdx as f64; let y2 = sheet.row_top(to.row) + tdy as f64; - Some(Anchor { cell, dx, dy, width: (x2 - x1).max(0.0) as f32, height: (y2 - y1).max(0.0) as f32 }) + // Excel stores "Move and size with cells" as a plain twoCellAnchor, "Move but don't + // size" as editAs="oneCell", and "Don't move or size" as editAs="absolute". + let mode = match a.attr("editAs") { + Some("absolute") => AnchorMode::Absolute, + Some("oneCell") => AnchorMode::MoveOnly, + _ => AnchorMode::MoveAndSize, + }; + Some(Anchor { cell, dx, dy, width: (x2 - x1).max(0.0) as f32, height: (y2 - y1).max(0.0) as f32, mode }) } "oneCellAnchor" => { let (cell, dx, dy) = marker(a.child("from")?); let (width, height) = ext_px(a.child("ext")); - Some(Anchor { cell, dx, dy, width, height }) + Some(Anchor { cell, dx, dy, width, height, mode: AnchorMode::MoveOnly }) } "absoluteAnchor" => { let pos = a.child("pos"); @@ -60,6 +67,7 @@ fn read_anchor(a: &El, sheet: &Sheet) -> Option { dy: (y - sheet.row_top(row)).max(0.0) as f32, width, height, + mode: AnchorMode::Absolute, }) } _ => None, @@ -198,8 +206,12 @@ fn read_shape(obj: &El, anchor: Anchor) -> Option { // ---------------------------------------------------------------- writing -/// ``/`` markers for a model anchor. -fn anchor_open(sheet: &Sheet, a: &Anchor) -> String { +/// The opening `` for a model anchor, carrying Excel's `editAs` when the mode +/// is not the default: omitted for "Move and size with cells", `editAs="oneCell"` for "Move but +/// don't size", `editAs="absolute"` for "Don't move or size". (Excel writes pictures this way; +/// the standalone `oneCellAnchor`/`absoluteAnchor` elements are read on import but not written.) +/// Returns the opening tag and the matching closing tag. +fn anchor_open(sheet: &Sheet, a: &Anchor) -> (String, &'static str) { let px = |v: f64| ((v.max(0.0)) * EMU_PER_PX).round() as i64; let x1 = sheet.col_left(a.cell.col) + a.dx as f64; let y1 = sheet.row_top(a.cell.row) + a.dy as f64; @@ -209,15 +221,21 @@ fn anchor_open(sheet: &Sheet, a: &Anchor) -> String { let tr = sheet.row_at(y2); let tdx = x2 - sheet.col_left(tc); let tdy = y2 - sheet.row_top(tr); - format!( - "{}{}{}{}{tc}{}{tr}{}", + let edit = match a.mode { + AnchorMode::MoveAndSize => "", + AnchorMode::MoveOnly => " editAs=\"oneCell\"", + AnchorMode::Absolute => " editAs=\"absolute\"", + }; + let open = format!( + "{}{}{}{}{tc}{}{tr}{}", a.cell.col, px(a.dx as f64), a.cell.row, px(a.dy as f64), px(tdx), px(tdy) - ) + ); + (open, "") } fn xfrm(a: &Anchor, tag: &str) -> String { @@ -244,9 +262,12 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String { ); for (i, o) in objs.iter().enumerate() { let id = i + 2; + let close_tag; match o { Obj::Image(img, rid) => { - s.push_str(&anchor_open(sheet, &img.anchor)); + let (open, close) = anchor_open(sheet, &img.anchor); + close_tag = close; + s.push_str(&open); let _ = write!( s, "{}", @@ -256,7 +277,9 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String { ); } Obj::Chart(ch, rid) => { - s.push_str(&anchor_open(sheet, &ch.anchor)); + let (open, close) = anchor_open(sheet, &ch.anchor); + close_tag = close; + s.push_str(&open); let _ = write!( s, "{}", @@ -265,7 +288,9 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String { ); } Obj::Shape(sh) => { - s.push_str(&anchor_open(sheet, &sh.anchor)); + let (open, close) = anchor_open(sheet, &sh.anchor); + close_tag = close; + s.push_str(&open); let (prst, tx) = match sh.kind { ShapeKind::Rectangle => ("rect", false), ShapeKind::RoundedRectangle => ("roundRect", false), @@ -314,7 +339,8 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String { s.push_str(""); } } - s.push_str(""); + s.push_str(""); + s.push_str(close_tag); } s.push_str(""); s diff --git a/crates/xlsx/src/tests/fixtures.rs b/crates/xlsx/src/tests/fixtures.rs index c843323..18d1179 100644 --- a/crates/xlsx/src/tests/fixtures.rs +++ b/crates/xlsx/src/tests/fixtures.rs @@ -279,6 +279,8 @@ fn drawing_with_picture_and_chart() { 5000 + 70108020 + "#; let chart = r#"Sales Data!$B$1Data!$A$2:$A$5Data!$B$2:$B$5 @@ -322,10 +324,14 @@ fn drawing_with_picture_and_chart() { assert_eq!(img.anchor.dx, 10.0); assert_eq!(img.anchor.width, 118.0); assert_eq!(img.anchor.height, 100.0); + // A plain twoCellAnchor is "Move and size with cells". + assert_eq!(img.anchor.mode, gridcraft_model::AnchorMode::MoveAndSize); let ch = &s.charts[0]; assert_eq!(ch.kind, gridcraft_model::ChartKind::Combo); assert_eq!(ch.title.as_deref(), Some("Sales")); assert_eq!(ch.anchor.width, 480.0); + // A oneCellAnchor is "Move but don't size with cells". + assert_eq!(ch.anchor.mode, gridcraft_model::AnchorMode::MoveOnly); assert_eq!(ch.series.len(), 2); assert_eq!(ch.series[0].name.as_deref(), Some("Data!$B$1")); assert_eq!(ch.series[0].categories.as_deref(), Some("Data!$A$2:$A$5")); @@ -336,4 +342,8 @@ fn drawing_with_picture_and_chart() { assert!(ch.data_labels && ch.gridlines); assert_eq!(ch.legend, gridcraft_model::LegendPos::Top); assert_eq!(ch.x_title.as_deref(), Some("Region")); + // A twoCellAnchor with editAs="absolute" is "Don't move or size with cells". + let sp = &s.shapes[0]; + assert_eq!(sp.kind, gridcraft_model::ShapeKind::Ellipse); + assert_eq!(sp.anchor.mode, gridcraft_model::AnchorMode::Absolute); } diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index bfc83b9..d7938a5 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -245,7 +245,8 @@ fn sample() -> Workbook { let png = b"\x89PNG\r\n\x1a\n0000IHDRfake".to_vec(); s.images.push(Image { id: 10, - anchor: Anchor { cell: at("C10"), dx: 5.0, dy: 4.0, width: 120.0, height: 80.0 }, + // A non-default anchor mode, to prove it survives an xlsx round-trip. + anchor: Anchor { cell: at("C10"), dx: 5.0, dy: 4.0, width: 120.0, height: 80.0, mode: AnchorMode::MoveOnly }, data: png, mime: "image/png".into(), alt: "Logo \"x\"".into(), @@ -253,7 +254,7 @@ fn sample() -> Workbook { s.shapes.push(Shape { id: 11, kind: ShapeKind::Ellipse, - anchor: Anchor { cell: at("E10"), dx: 0.0, dy: 0.0, width: 64.0, height: 40.0 }, + anchor: Anchor { cell: at("E10"), dx: 0.0, dy: 0.0, width: 64.0, height: 40.0, mode: AnchorMode::Absolute }, fill: Color::Rgb(0x4472C4), line: Color::Rgb(0x000000), text: "Hi\nthere".into(), @@ -261,7 +262,7 @@ fn sample() -> Workbook { s.shapes.push(Shape { id: 12, kind: ShapeKind::Arrow, - anchor: Anchor { cell: at("E14"), dx: 0.0, dy: 0.0, width: 64.0, height: 0.0 }, + anchor: Anchor { cell: at("E14"), dx: 0.0, dy: 0.0, width: 64.0, height: 0.0, ..Default::default() }, fill: Color::Auto, line: Color::Rgb(0xFF0000), text: String::new(), @@ -269,7 +270,7 @@ fn sample() -> Workbook { s.shapes.push(Shape { id: 13, kind: ShapeKind::TextBox, - anchor: Anchor { cell: at("E16"), dx: 0.0, dy: 0.0, width: 64.0, height: 40.0 }, + anchor: Anchor { cell: at("E16"), dx: 0.0, dy: 0.0, width: 64.0, height: 40.0, ..Default::default() }, fill: Color::Auto, line: Color::Auto, text: "box".into(), @@ -277,7 +278,7 @@ fn sample() -> Workbook { let base_chart = Chart { id: 20, kind: ChartKind::ColumnClustered, - anchor: Anchor { cell: at("J5"), dx: 0.0, dy: 0.0, width: 480.0, height: 288.0 }, + anchor: Anchor { cell: at("J5"), dx: 0.0, dy: 0.0, width: 480.0, height: 288.0, ..Default::default() }, title: Some("Sales".into()), series: vec![Series { name: Some("'Data & Stuff'!$B$1".into()), @@ -420,6 +421,10 @@ fn full_roundtrip() { ia.cell == ib.cell && close(ia.dx, ib.dx) && close(ia.dy, ib.dy) && close(ia.width, ib.width) && close(ia.height, ib.height), "{ia:?} {ib:?}" ); + // The anchor mode (Excel's editAs) survives the round-trip. + assert_eq!(ib.mode, AnchorMode::MoveOnly, "image anchor mode"); + assert_eq!(b.shapes[0].anchor.mode, AnchorMode::Absolute, "shape anchor mode"); + assert_eq!(b.shapes[1].anchor.mode, AnchorMode::MoveAndSize, "default anchor mode"); assert_eq!(b.shapes.len(), 3); for (x, y) in a.shapes.iter().zip(&b.shapes) { assert_eq!((x.kind, &x.text, x.fill, x.line), (y.kind, &y.text, y.fill, y.line));