Skip to content

Commit ba96e8e

Browse files
sbroenneStefan Broenner
andauthored
feat: Chart Positioning Enhancements (#381) (#383)
* feat: Add chart positioning enhancements (#381) - Add cell geometry (Left, Top, Width, Height) to RangeInfoResult get-info - Add TopLeftCell, BottomRightCell anchor addresses to chart read - Add Placement mode (1=move/size, 2=move, 3=floating) to chart read - Add fit-to-range action to position/resize charts to cell boundaries - Add set-placement action to control chart resize behavior - Update operation count: 194 -> 196 operations across 22 tools - Add SetPlacement to audit script known exceptions (exposed via ChartConfigAction) Co-authored-by: GitHub Copilot * feat(chart): Add targetRange parameter for one-step cell-relative positioning (#381) - Add targetRange parameter to create-from-range and create-from-pivottable actions - Auto-calls FitToRange when targetRange provided - Update tool XML docs with CRITICAL positioning warnings - Add LLM test scenario (excel-chart-positioning-test.yaml) - Update prompts and skills documentation --------- Co-authored-by: Stefan Broenner <[email protected]>
1 parent 2e0525d commit ba96e8e

18 files changed

Lines changed: 1193 additions & 28 deletions

File tree

scripts/audit-core-coverage.ps1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -497,7 +497,7 @@ if ($CheckNaming) {
497497
"ChartAction" = @("SetSourceRange", "AddSeries", "RemoveSeries", "SetChartType", "SetTitle",
498498
"SetAxisTitle", "GetAxisNumberFormat", "SetAxisNumberFormat", "ShowLegend", "SetStyle",
499499
"SetDataLabels", "GetAxisScale", "SetAxisScale", "GetGridlines", "SetGridlines", "SetSeriesFormat",
500-
"ListTrendlines", "AddTrendline", "DeleteTrendline", "SetTrendline") # Methods moved to ChartConfigAction
500+
"ListTrendlines", "AddTrendline", "DeleteTrendline", "SetTrendline", "SetPlacement") # Methods moved to ChartConfigAction
501501
}
502502

503503
$hasNamingIssues = $false

skills/excel-mcp/references/excel_chart.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,41 @@ Specialized: `Waterfall`, `Funnel`, `Treemap`, `Sunburst`, `BoxWhisker`, `Histog
110110
3. **Use gridlines sparingly**: Minor gridlines often add clutter
111111
4. **Trendlines for insights**: Add R² to show fit quality
112112
5. **Data labels placement**: `OutsideEnd` for bar charts, `Center` for pie charts
113+
114+
## Chart Positioning (CRITICAL)
115+
116+
**ALWAYS position charts to avoid overlapping data:**
117+
118+
### Use targetRange (PREFERRED - One Step)
119+
```
120+
excel_chart(create-from-range, sourceRange='A1:B10', chartType='Line', targetRange='F2:K15')
121+
```
122+
Creates chart AND positions it to cell range in one call.
123+
124+
### Check Used Range First
125+
```
126+
excel_range(action: 'get-used-range') → e.g., "Sheet1!A1:D20"
127+
```
128+
129+
### Position with Coordinates
130+
```
131+
excel_chart(create-from-range, sourceRange: 'A1:B10', left: 360, top: 20)
132+
# left/top in points (72 points = 1 inch)
133+
```
134+
135+
### Use FitToRange (After Creation)
136+
```
137+
excel_chart(create-from-range, ...) -> chartName
138+
excel_chart(fit-to-range, chartName, rangeAddress: 'F2:K15')
139+
# Reposition existing chart to cell range
140+
```
141+
142+
### Position Estimates
143+
- Rows: ~15 points per row (varies with row height)
144+
- Columns: ~60 points per column (varies with column width)
145+
- Default chart: 400x300 points
146+
147+
### Positioning Workflow
148+
1. `get-used-range` -> Identify data boundaries
149+
2. **Option A (Preferred)**: Use `targetRange='F2:K15'` in create call
150+
3. **Option B**: Calculate `(lastRow + 2) * 15` for top, or `(lastCol + 2) * 60` for left

src/ExcelMcp.Core/Commands/Chart/ChartCommands.Appearance.cs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,41 @@ public void SetStyle(IExcelBatch batch, string chartName, int styleId)
305305
});
306306
}
307307

308+
/// <inheritdoc />
309+
public void SetPlacement(IExcelBatch batch, string chartName, int placement)
310+
{
311+
batch.Execute((ctx, ct) =>
312+
{
313+
// Find chart by name
314+
var findResult = FindChart(ctx.Book, chartName);
315+
if (findResult.Chart == null)
316+
{
317+
throw new InvalidOperationException($"Chart '{chartName}' not found in workbook.");
318+
}
319+
320+
try
321+
{
322+
// Validate placement value (xlMoveAndSize=1, xlMove=2, xlFreeFloating=3)
323+
if (placement < 1 || placement > 3)
324+
{
325+
throw new ArgumentException(
326+
$"Placement must be 1 (move and size with cells), 2 (move only), or 3 (free floating). Provided: {placement}",
327+
nameof(placement));
328+
}
329+
330+
// Set placement on the shape (ChartObject)
331+
findResult.Shape.Placement = placement;
332+
333+
return 0; // Void operation completed
334+
}
335+
finally
336+
{
337+
if (findResult.Shape != null) ComUtilities.Release(ref findResult.Shape!);
338+
if (findResult.Chart != null) ComUtilities.Release(ref findResult.Chart!);
339+
}
340+
});
341+
}
342+
308343
// === DATA LABELS ===
309344

310345
/// <inheritdoc />
@@ -1129,4 +1164,49 @@ public void SetTrendline(
11291164
}
11301165
});
11311166
}
1167+
1168+
/// <inheritdoc />
1169+
public void FitToRange(IExcelBatch batch, string chartName, string sheetName, string rangeAddress)
1170+
{
1171+
batch.Execute((ctx, ct) =>
1172+
{
1173+
// Find chart by name
1174+
var findResult = FindChart(ctx.Book, chartName);
1175+
if (findResult.Chart == null)
1176+
{
1177+
throw new InvalidOperationException($"Chart '{chartName}' not found in workbook.");
1178+
}
1179+
1180+
dynamic? worksheet = null;
1181+
dynamic? range = null;
1182+
1183+
try
1184+
{
1185+
// Get the target range
1186+
worksheet = ctx.Book.Worksheets.Item(sheetName);
1187+
range = worksheet.Range[rangeAddress];
1188+
1189+
// Get range geometry
1190+
double left = Convert.ToDouble(range.Left);
1191+
double top = Convert.ToDouble(range.Top);
1192+
double width = Convert.ToDouble(range.Width);
1193+
double height = Convert.ToDouble(range.Height);
1194+
1195+
// Apply to chart shape
1196+
findResult.Shape.Left = left;
1197+
findResult.Shape.Top = top;
1198+
findResult.Shape.Width = width;
1199+
findResult.Shape.Height = height;
1200+
1201+
return 0; // Void operation completed
1202+
}
1203+
finally
1204+
{
1205+
if (range != null) ComUtilities.Release(ref range!);
1206+
if (worksheet != null) ComUtilities.Release(ref worksheet!);
1207+
if (findResult.Shape != null) ComUtilities.Release(ref findResult.Shape!);
1208+
if (findResult.Chart != null) ComUtilities.Release(ref findResult.Chart!);
1209+
}
1210+
});
1211+
}
11321212
}

src/ExcelMcp.Core/Commands/Chart/ChartResults.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,23 @@ public class ChartInfo
4747

4848
/// <summary>Number of data series</summary>
4949
public int SeriesCount { get; set; }
50+
51+
/// <summary>
52+
/// Cell address of top-left anchor (e.g., "$A$1").
53+
/// Chart's top-left corner overlaps this cell.
54+
/// </summary>
55+
public string? TopLeftCell { get; set; }
56+
57+
/// <summary>
58+
/// Cell address of bottom-right anchor (e.g., "$D$10").
59+
/// Chart's bottom-right corner overlaps this cell.
60+
/// </summary>
61+
public string? BottomRightCell { get; set; }
62+
63+
/// <summary>
64+
/// Chart placement mode: 1=Move and size with cells, 2=Move but don't size with cells, 3=Don't move or size with cells
65+
/// </summary>
66+
public int? Placement { get; set; }
5067
}
5168

5269
/// <summary>
@@ -84,6 +101,23 @@ public class ChartInfoResult : OperationResult
84101
/// <summary>Height in points</summary>
85102
public double Height { get; set; }
86103

104+
/// <summary>
105+
/// Cell address of top-left anchor (e.g., "$A$1").
106+
/// Chart's top-left corner overlaps this cell.
107+
/// </summary>
108+
public string? TopLeftCell { get; set; }
109+
110+
/// <summary>
111+
/// Cell address of bottom-right anchor (e.g., "$D$10").
112+
/// Chart's bottom-right corner overlaps this cell.
113+
/// </summary>
114+
public string? BottomRightCell { get; set; }
115+
116+
/// <summary>
117+
/// Chart placement mode: 1=Move and size with cells, 2=Move but don't size with cells, 3=Don't move or size with cells
118+
/// </summary>
119+
public int? Placement { get; set; }
120+
87121
/// <summary>Chart title text</summary>
88122
public string? Title { get; set; }
89123

src/ExcelMcp.Core/Commands/Chart/IChartCommands.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,24 @@ void ShowLegend(
211211
/// <param name="styleId">Style number (1-48)</param>
212212
void SetStyle(IExcelBatch batch, string chartName, int styleId);
213213

214+
/// <summary>
215+
/// Sets chart placement mode (how chart responds when underlying cells are resized).
216+
/// </summary>
217+
/// <param name="batch">Excel batch session</param>
218+
/// <param name="chartName">Name of the chart</param>
219+
/// <param name="placement">Placement mode: 1=Move and size with cells, 2=Move but don't size, 3=Don't move or size</param>
220+
void SetPlacement(IExcelBatch batch, string chartName, int placement);
221+
222+
/// <summary>
223+
/// Fits a chart to a cell range by setting position and size to match the range bounds.
224+
/// Uses the range's Left, Top, Width, Height properties.
225+
/// </summary>
226+
/// <param name="batch">Excel batch session</param>
227+
/// <param name="chartName">Name of the chart</param>
228+
/// <param name="sheetName">Worksheet containing the range</param>
229+
/// <param name="rangeAddress">Target range (e.g., "A1:D10")</param>
230+
void FitToRange(IExcelBatch batch, string chartName, string sheetName, string rangeAddress);
231+
214232
// === DATA LABELS ===
215233

216234
/// <summary>

src/ExcelMcp.Core/Commands/Chart/PivotChartStrategy.cs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,46 @@ public ChartInfo GetInfo(dynamic chart, string chartName, string sheetName, dyna
3838
Height = Convert.ToDouble(shape.Height)
3939
};
4040

41+
// Get anchor cells and placement mode
42+
dynamic? topLeftCell = null;
43+
dynamic? bottomRightCell = null;
44+
try
45+
{
46+
topLeftCell = shape.TopLeftCell;
47+
info.TopLeftCell = topLeftCell.Address?.ToString();
48+
}
49+
catch
50+
{
51+
// TopLeftCell not available - optional property
52+
}
53+
finally
54+
{
55+
if (topLeftCell != null) ComUtilities.Release(ref topLeftCell!);
56+
}
57+
58+
try
59+
{
60+
bottomRightCell = shape.BottomRightCell;
61+
info.BottomRightCell = bottomRightCell.Address?.ToString();
62+
}
63+
catch
64+
{
65+
// BottomRightCell not available - optional property
66+
}
67+
finally
68+
{
69+
if (bottomRightCell != null) ComUtilities.Release(ref bottomRightCell!);
70+
}
71+
72+
try
73+
{
74+
info.Placement = Convert.ToInt32(shape.Placement);
75+
}
76+
catch
77+
{
78+
// Placement not available - optional property
79+
}
80+
4181
// Get linked PivotTable name
4282
dynamic? pivotLayout = null;
4383
dynamic? pivotTable = null;
@@ -90,6 +130,46 @@ public ChartInfoResult GetDetailedInfo(dynamic chart, string chartName, string s
90130
Height = Convert.ToDouble(shape.Height)
91131
};
92132

133+
// Get anchor cells and placement mode
134+
dynamic? topLeftCell = null;
135+
dynamic? bottomRightCell = null;
136+
try
137+
{
138+
topLeftCell = shape.TopLeftCell;
139+
info.TopLeftCell = topLeftCell.Address?.ToString();
140+
}
141+
catch
142+
{
143+
// TopLeftCell not available - optional property
144+
}
145+
finally
146+
{
147+
if (topLeftCell != null) ComUtilities.Release(ref topLeftCell!);
148+
}
149+
150+
try
151+
{
152+
bottomRightCell = shape.BottomRightCell;
153+
info.BottomRightCell = bottomRightCell.Address?.ToString();
154+
}
155+
catch
156+
{
157+
// BottomRightCell not available - optional property
158+
}
159+
finally
160+
{
161+
if (bottomRightCell != null) ComUtilities.Release(ref bottomRightCell!);
162+
}
163+
164+
try
165+
{
166+
info.Placement = Convert.ToInt32(shape.Placement);
167+
}
168+
catch
169+
{
170+
// Placement not available - optional property
171+
}
172+
93173
// Get linked PivotTable name
94174
dynamic? pivotLayout = null;
95175
dynamic? pivotTable = null;

0 commit comments

Comments
 (0)