Objects: anchor modes — images/charts/shapes follow cells on sort (#5)
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.
This commit is contained in:
parent
c6b6f4177c
commit
cbc2430f4f
|
|
@ -283,6 +283,33 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec<SortKey>, by_co
|
|||
// Row heights travel with rows.
|
||||
let heights: Vec<Option<LineInfo>> =
|
||||
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<u32> = {
|
||||
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<Anchor> =
|
||||
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<SortKey>, 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<SortKey>, 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<Json> {
|
||||
|
|
|
|||
|
|
@ -82,7 +82,15 @@ fn stroke(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -468,13 +468,13 @@ fn selection_pane(s: &mut Session, _: &Json) -> Result<Json> {
|
|||
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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,6 +124,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
)),
|
||||
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<Json> {
|
|||
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<Json> {
|
|||
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<Json> {
|
|||
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<Json> {
|
|||
})
|
||||
}
|
||||
|
||||
/// 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<Json> {
|
||||
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<Json> {
|
||||
|
|
@ -1134,7 +1167,7 @@ fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -146,6 +146,30 @@ pub(crate) fn bool_param(p: &Json, key: &str) -> Option<bool> {
|
|||
pub(crate) fn u32_param(p: &Json, key: &str) -> Option<u32> {
|
||||
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<gridcraft_model::AnchorMode> {
|
||||
parse_anchor_mode(str_param(p, "mode").or_else(|| str_param(p, "anchorMode"))?)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_anchor_mode(s: &str) -> Option<gridcraft_model::AnchorMode> {
|
||||
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<Json> {
|
||||
Ok(Json::Null)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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<u32>) -> 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<u32> {
|
||||
|
|
@ -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<Anchor> = sh.charts.iter().enumerate().map(|(i, o)| anchored(i, o.anchor)).collect();
|
||||
let new_images: Vec<Anchor> = 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<Anchor> = 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) {
|
||||
|
|
|
|||
|
|
@ -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<u8> {
|
||||
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<CellRef> {
|
||||
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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; `<xdr:twoCellAnchor>`).
|
||||
#[default]
|
||||
MoveAndSize,
|
||||
/// Move but don't size with cells (`<xdr:twoCellAnchor editAs="oneCell">` / `<xdr:oneCellAnchor>`).
|
||||
MoveOnly,
|
||||
/// Don't move or size with cells (`editAs="absolute"` / `<xdr:absoluteAnchor>`).
|
||||
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)]
|
||||
|
|
|
|||
|
|
@ -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<Anchor> {
|
|||
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<Anchor> {
|
|||
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<Shape> {
|
|||
|
||||
// ---------------------------------------------------------------- writing
|
||||
|
||||
/// `<xdr:from>`/`<xdr:to>` markers for a model anchor.
|
||||
fn anchor_open(sheet: &Sheet, a: &Anchor) -> String {
|
||||
/// The opening `<xdr:twoCellAnchor>` 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!(
|
||||
"<xdr:twoCellAnchor editAs=\"oneCell\"><xdr:from><xdr:col>{}</xdr:col><xdr:colOff>{}</xdr:colOff><xdr:row>{}</xdr:row><xdr:rowOff>{}</xdr:rowOff></xdr:from><xdr:to><xdr:col>{tc}</xdr:col><xdr:colOff>{}</xdr:colOff><xdr:row>{tr}</xdr:row><xdr:rowOff>{}</xdr:rowOff></xdr:to>",
|
||||
let edit = match a.mode {
|
||||
AnchorMode::MoveAndSize => "",
|
||||
AnchorMode::MoveOnly => " editAs=\"oneCell\"",
|
||||
AnchorMode::Absolute => " editAs=\"absolute\"",
|
||||
};
|
||||
let open = format!(
|
||||
"<xdr:twoCellAnchor{edit}><xdr:from><xdr:col>{}</xdr:col><xdr:colOff>{}</xdr:colOff><xdr:row>{}</xdr:row><xdr:rowOff>{}</xdr:rowOff></xdr:from><xdr:to><xdr:col>{tc}</xdr:col><xdr:colOff>{}</xdr:colOff><xdr:row>{tr}</xdr:row><xdr:rowOff>{}</xdr:rowOff></xdr:to>",
|
||||
a.cell.col,
|
||||
px(a.dx as f64),
|
||||
a.cell.row,
|
||||
px(a.dy as f64),
|
||||
px(tdx),
|
||||
px(tdy)
|
||||
)
|
||||
);
|
||||
(open, "</xdr:twoCellAnchor>")
|
||||
}
|
||||
|
||||
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,
|
||||
"<xdr:pic><xdr:nvPicPr><xdr:cNvPr id=\"{id}\" name=\"Picture {}\" descr=\"{}\"/><xdr:cNvPicPr><a:picLocks noChangeAspect=\"1\"/></xdr:cNvPicPr></xdr:nvPicPr><xdr:blipFill><a:blip r:embed=\"{rid}\"/><a:stretch><a:fillRect/></a:stretch></xdr:blipFill><xdr:spPr>{}<a:prstGeom prst=\"rect\"><a:avLst/></a:prstGeom></xdr:spPr></xdr:pic>",
|
||||
|
|
@ -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,
|
||||
"<xdr:graphicFrame macro=\"\"><xdr:nvGraphicFramePr><xdr:cNvPr id=\"{id}\" name=\"Chart {}\"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>{}<a:graphic><a:graphicData uri=\"http://schemas.openxmlformats.org/drawingml/2006/chart\"><c:chart r:id=\"{rid}\"/></a:graphicData></a:graphic></xdr:graphicFrame>",
|
||||
|
|
@ -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("</xdr:sp>");
|
||||
}
|
||||
}
|
||||
s.push_str("<xdr:clientData/></xdr:twoCellAnchor>");
|
||||
s.push_str("<xdr:clientData/>");
|
||||
s.push_str(close_tag);
|
||||
}
|
||||
s.push_str("</xdr:wsDr>");
|
||||
s
|
||||
|
|
|
|||
|
|
@ -279,6 +279,8 @@ fn drawing_with_picture_and_chart() {
|
|||
<xdr:pic><xdr:nvPicPr><xdr:cNvPr id="2" name="P" descr="logo"/><xdr:cNvPicPr/></xdr:nvPicPr><xdr:blipFill><a:blip r:embed="rId1"/></xdr:blipFill><xdr:spPr/></xdr:pic><xdr:clientData/></xdr:twoCellAnchor>
|
||||
<xdr:oneCellAnchor><xdr:from><xdr:col>5</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>0</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from><xdr:ext cx="4572000" cy="2743200"/>
|
||||
<xdr:graphicFrame><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="C"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr><xdr:xfrm/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" r:id="rId2"/></a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:oneCellAnchor>
|
||||
<xdr:twoCellAnchor editAs="absolute"><xdr:from><xdr:col>7</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>1</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from><xdr:to><xdr:col>8</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>2</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>
|
||||
<xdr:sp><xdr:nvSpPr><xdr:cNvPr id="4" name="S"/><xdr:cNvSpPr/></xdr:nvSpPr><xdr:spPr><a:prstGeom prst="ellipse"><a:avLst/></a:prstGeom></xdr:spPr></xdr:sp><xdr:clientData/></xdr:twoCellAnchor>
|
||||
</xdr:wsDr>"#;
|
||||
let chart = r#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><c:chart><c:title><c:tx><c:rich><a:p><a:r><a:t>Sales</a:t></a:r></a:p></c:rich></c:tx></c:title><c:plotArea>
|
||||
<c:barChart><c:barDir val="bar"/><c:grouping val="stacked"/><c:ser><c:idx val="0"/><c:tx><c:strRef><c:f>Data!$B$1</c:f></c:strRef></c:tx><c:spPr><a:solidFill><a:srgbClr val="FF0000"/></a:solidFill></c:spPr><c:cat><c:strRef><c:f>Data!$A$2:$A$5</c:f></c:strRef></c:cat><c:val><c:numRef><c:f>Data!$B$2:$B$5</c:f></c:numRef></c:val></c:ser><c:dLbls><c:showVal val="1"/></c:dLbls><c:axId val="1"/><c:axId val="2"/></c:barChart>
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Reference in New Issue