8cb1f9f479
Publish SDK (PyPI) / publish (push) Has been cancelled
Publish SDK (npm) / publish (@aionui/officecli-sdk) (push) Has been cancelled
SDK smoke / smoke (windows-latest) (push) Has been cancelled
Publish SDK (npm) / publish (@officecli/officecli-sdk) (push) Has been cancelled
Publish SDK (npm) / publish (@officecli/sdk) (push) Has been cancelled
Publish SDK (npm) / publish (officecli-sdk) (push) Has been cancelled
SDK smoke / smoke (macos-latest) (push) Has been cancelled
SDK smoke / smoke (ubuntu-latest) (push) Has been cancelled
Skill parity / diff (push) Has been cancelled
730 lines
34 KiB
C#
730 lines
34 KiB
C#
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
using DocumentFormat.OpenXml;
|
|
using C = DocumentFormat.OpenXml.Drawing.Charts;
|
|
|
|
namespace OfficeCli.Core;
|
|
|
|
/// <summary>
|
|
/// Shared chart build/read/set logic used by PPTX, Excel, and Word handlers.
|
|
/// All methods operate on ChartPart / C.Chart / C.PlotArea — independent of host document type.
|
|
/// </summary>
|
|
internal static partial class ChartHelper
|
|
{
|
|
// ==================== Parse Helpers ====================
|
|
|
|
internal static (string kind, bool is3D, bool stacked, bool percentStacked) ParseChartType(string chartType)
|
|
{
|
|
var ct = SchemaKeyNormalizer.Normalize(chartType);
|
|
var is3D = ct.EndsWith("3d") || ct.Contains("3d");
|
|
ct = ct.Replace("3d", "");
|
|
|
|
// OOXML has no Scatter3D or Radar3D variant — CT_ScatterChart and
|
|
// CT_RadarChart are 2D-only. Previously `scatter3d`/`radar3d` silently
|
|
// had the `3d` stripped and became plain scatter/radar, losing caller
|
|
// intent (round-trip returned `scatter`/`radar`, real PowerPoint
|
|
// rendered flat). Reject these forms explicitly with a helpful error
|
|
// pointing at the supported alternatives.
|
|
if (is3D && (ct == "scatter" || ct == "xy" || ct == "radar" || ct == "spider"))
|
|
{
|
|
throw new ArgumentException(
|
|
$"Chart type '{chartType}' is not supported. " +
|
|
"OOXML has no Scatter3D or Radar3D variant — both CT_ScatterChart and CT_RadarChart " +
|
|
"are 2D-only. Use 'scatter' / 'radar' (optionally with radarStyle=filled|marker|standard).");
|
|
}
|
|
|
|
var stacked = ct.Contains("stacked") && !ct.Contains("percent");
|
|
var percentStacked = ct.Contains("percentstacked") || ct.Contains("pstacked");
|
|
ct = ct.Replace("percentstacked", "").Replace("pstacked", "").Replace("stacked", "");
|
|
|
|
var kind = ct switch
|
|
{
|
|
"bar" => "bar",
|
|
"column" or "col" => "column",
|
|
"line" => "line",
|
|
"pie" => "pie",
|
|
"pieofpie" => "pieofpie",
|
|
"barofpie" => "barofpie",
|
|
"doughnut" or "donut" => "doughnut",
|
|
"area" => "area",
|
|
"scatter" or "xy" => "scatter",
|
|
"bubble" => "bubble",
|
|
"radar" or "spider" => "radar",
|
|
"stock" or "ohlc" => "stock",
|
|
"combo" => "combo",
|
|
"waterfall" or "wf" => "waterfall",
|
|
_ => throw new ArgumentException(
|
|
$"Unknown chart type: '{chartType}'. Supported types: " +
|
|
"column, bar, line, pie, doughnut, area, scatter, bubble, radar, stock, combo, waterfall, " +
|
|
"funnel, treemap, sunburst, boxWhisker, histogram, pareto. " +
|
|
"Modifiers: 3d (e.g. column3d), stacked (e.g. stackedColumn), percentStacked (e.g. percentStackedBar).")
|
|
};
|
|
|
|
return (kind, is3D, stacked, percentStacked);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extended series info that may contain cell references instead of literal data.
|
|
/// </summary>
|
|
internal class SeriesInfo
|
|
{
|
|
public string Name { get; set; } = "";
|
|
public double[]? Values { get; set; }
|
|
public string? ValuesRef { get; set; } // e.g. "Sheet1!$B$2:$B$13"
|
|
public string? CategoriesRef { get; set; } // e.g. "Sheet1!$A$2:$A$13"
|
|
// R52 bt-3: bubble charts store per-point sizes in a third series
|
|
// dimension. The literal data round-trips via Format["bubbleSize"];
|
|
// BubbleSizeRef carries the external cell range so dump→replay
|
|
// preserves the source <c:numRef> instead of falling back to the
|
|
// BuildBubbleChart default (size = y-values).
|
|
public double[]? BubbleSizeValues { get; set; }
|
|
public string? BubbleSizeRef { get; set; } // e.g. "[Book1.xlsx]Sheet1!$D$1:$D$3"
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns true if the value looks like a cell range reference (contains '!' or matches A1:B2 pattern).
|
|
/// </summary>
|
|
internal static bool IsRangeReference(string value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value)) return false;
|
|
if (value.Contains('!')) return true;
|
|
// Match patterns like A1:B13, $A$1:$B$13, AA1:ZZ999
|
|
return System.Text.RegularExpressions.Regex.IsMatch(value.Trim(),
|
|
@"^\$?[A-Za-z]+\$?\d+:\$?[A-Za-z]+\$?\d+$");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns true if the value looks like a single cell reference with an
|
|
/// explicit sheet prefix (Sheet1!A1, Sheet1!$A$1, 'My Sheet'!A1:A1). Used
|
|
/// to detect when a series.name / chart title parameter should be emitted
|
|
/// as a c:strRef instead of literal c:v.
|
|
///
|
|
/// Bare cell-shaped tokens (e.g. "Q1", "A1", "B2") are deliberately NOT
|
|
/// treated as cell references — they collide with common literal labels
|
|
/// (quarter codes, product names) and emitting a strRef without an
|
|
/// external workbook backing causes real PowerPoint to render no title /
|
|
/// no series name at all (data loss). Callers wanting a cell reference
|
|
/// must qualify with the sheet name.
|
|
/// </summary>
|
|
internal static bool IsCellReference(string value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value)) return false;
|
|
var trimmed = value.Trim();
|
|
// Mandatory sheet prefix (Sheet1! or 'Sheet with spaces'!), single
|
|
// cell A1 or $A$1, optionally followed by :A1 range of size 1.
|
|
return System.Text.RegularExpressions.Regex.IsMatch(trimmed,
|
|
@"^(?:'[^']+'!|[A-Za-z_][\w\.]*!)\$?[A-Za-z]+\$?\d+(?::\$?[A-Za-z]+\$?\d+)?$");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Normalizes a single-cell reference for use inside a chart's c:strRef/c:f.
|
|
/// Ensures absolute ($col$row) form and preserves any sheet prefix. If the
|
|
/// input is a A1:A1 style single-cell range, the range form is kept so the
|
|
/// output matches what Excel writes when a user points the Name field at a
|
|
/// single cell via the dialog.
|
|
/// </summary>
|
|
internal static string NormalizeCellReference(string value)
|
|
{
|
|
var trimmed = value.Trim();
|
|
string sheetPart = "";
|
|
string cellPart = trimmed;
|
|
var bangIdx = trimmed.IndexOf('!');
|
|
if (bangIdx >= 0)
|
|
{
|
|
sheetPart = trimmed[..(bangIdx + 1)];
|
|
cellPart = trimmed[(bangIdx + 1)..];
|
|
}
|
|
var parts = cellPart.Split(':');
|
|
for (int i = 0; i < parts.Length; i++)
|
|
parts[i] = AddAbsoluteMarkers(parts[i]);
|
|
return sheetPart + string.Join(":", parts);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Normalizes a range reference by adding $ signs for absolute references.
|
|
/// If no sheet prefix, prepends defaultSheet.
|
|
/// </summary>
|
|
internal static string NormalizeRangeReference(string value, string? defaultSheet = null)
|
|
{
|
|
var trimmed = value.Trim();
|
|
string sheetPart = "";
|
|
string rangePart = trimmed;
|
|
|
|
var bangIdx = trimmed.IndexOf('!');
|
|
if (bangIdx >= 0)
|
|
{
|
|
sheetPart = trimmed[..(bangIdx + 1)];
|
|
rangePart = trimmed[(bangIdx + 1)..];
|
|
}
|
|
else if (!string.IsNullOrEmpty(defaultSheet))
|
|
{
|
|
sheetPart = defaultSheet + "!";
|
|
}
|
|
|
|
// Add $ signs to cell refs if not already present
|
|
var parts = rangePart.Split(':');
|
|
for (int i = 0; i < parts.Length; i++)
|
|
parts[i] = AddAbsoluteMarkers(parts[i]);
|
|
|
|
return sheetPart + string.Join(":", parts);
|
|
}
|
|
|
|
private static string AddAbsoluteMarkers(string cellRef)
|
|
{
|
|
// Already has $ signs — return as-is
|
|
if (cellRef.Contains('$')) return cellRef;
|
|
|
|
// Split into column letters and row digits
|
|
int firstDigit = 0;
|
|
for (int i = 0; i < cellRef.Length; i++)
|
|
{
|
|
if (char.IsDigit(cellRef[i])) { firstDigit = i; break; }
|
|
}
|
|
if (firstDigit == 0) return cellRef; // no digits found
|
|
|
|
var col = cellRef[..firstDigit];
|
|
var row = cellRef[firstDigit..];
|
|
return $"${col}${row}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parse series data supporting both legacy format and new dotted syntax with cell references.
|
|
/// Dotted syntax: series1.name=Sales, series1.values=Sheet1!B2:B13, series1.categories=Sheet1!A2:A13
|
|
/// Legacy: series1=Sales:10,20,30 or data=Sales:10,20,30;Cost:5,8,12
|
|
/// </summary>
|
|
internal static List<(string name, double[] values)> ParseSeriesData(Dictionary<string, string> properties)
|
|
{
|
|
// CONSISTENCY(chart-series-name-alias): `series{N}Name=` flat form
|
|
// is a natural alias for the dotted `series{N}.name=`. Rewrite so
|
|
// both the dotted and legacy branches below see the canonical
|
|
// `series{N}.name` key. TryGetValue on the original `series{N}Name`
|
|
// key still fires (preserved tracking) — we add the dotted alias.
|
|
NormalizeFlatSeriesNameAliases(properties);
|
|
|
|
// Check for dotted syntax first
|
|
var extSeries = ParseSeriesDataExtended(properties);
|
|
if (extSeries != null && extSeries.Count > 0 && extSeries.Any(s => s.ValuesRef != null || s.CategoriesRef != null))
|
|
{
|
|
// Dotted syntax with references. Literal values may still ride
|
|
// alongside via `data=` / `series{N}=` (the batch emitter dumps
|
|
// both the cached point values AND the source cell-range ref) —
|
|
// merge them positionally so the built numLit carries the real
|
|
// data and the numRef rewrite preserves it as numCache. Without
|
|
// the merge a ref-bearing series came back with an EMPTY value
|
|
// list → numRef with no numCache → real PowerPoint silently
|
|
// renders a blank chart.
|
|
var literalSeries = ParseLiteralSeriesData(properties);
|
|
var merged = new List<(string name, double[] values)>(extSeries.Count);
|
|
for (int i = 0; i < extSeries.Count; i++)
|
|
{
|
|
var s = extSeries[i];
|
|
var name = s.Name;
|
|
var vals = s.Values ?? Array.Empty<double>();
|
|
if (i < literalSeries.Count)
|
|
{
|
|
if (vals.Length == 0) vals = literalSeries[i].values;
|
|
if (!properties.ContainsKey($"series{i + 1}.name")
|
|
&& literalSeries[i].name.Length > 0)
|
|
name = literalSeries[i].name;
|
|
}
|
|
merged.Add((name, vals));
|
|
}
|
|
for (int i = extSeries.Count; i < literalSeries.Count; i++)
|
|
merged.Add(literalSeries[i]);
|
|
return merged;
|
|
}
|
|
|
|
return ParseLiteralSeriesData(properties);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parse literal series data from `data=Name1:v1,v2;...` or the legacy
|
|
/// `series{N}=Name:v1,v2` / dotted `series{N}.values=v1,v2` keys.
|
|
/// </summary>
|
|
private static List<(string name, double[] values)> ParseLiteralSeriesData(Dictionary<string, string> properties)
|
|
{
|
|
var result = new List<(string name, double[] values)>();
|
|
|
|
if (properties.TryGetValue("data", out var dataStr))
|
|
{
|
|
// Determine series delimiter: use ';' if present, otherwise detect
|
|
// comma-separated name:value pairs (e.g. "Q1:40,Q2:55,Q3:70")
|
|
string[] seriesParts;
|
|
if (dataStr.Contains(';'))
|
|
{
|
|
seriesParts = dataStr.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
|
}
|
|
else
|
|
{
|
|
// Check if comma-separated parts each contain a colon (name:value pairs)
|
|
var commaParts = dataStr.Split(',', StringSplitOptions.RemoveEmptyEntries);
|
|
if (commaParts.Length > 1 && commaParts.All(p => p.Contains(':')))
|
|
seriesParts = commaParts;
|
|
else
|
|
seriesParts = new[] { dataStr };
|
|
}
|
|
|
|
foreach (var seriesPart in seriesParts)
|
|
{
|
|
// Split on the LAST colon: the value list is colon-free, so the
|
|
// rightmost colon separates a (possibly colon-containing) series
|
|
// name from its values, e.g. "Persons (Data year: 2021):1,2,3".
|
|
var colonIdx = seriesPart.LastIndexOf(':');
|
|
if (colonIdx < 0) continue;
|
|
var name = seriesPart[..colonIdx].Trim();
|
|
var valStr = seriesPart[(colonIdx + 1)..].Trim();
|
|
if (string.IsNullOrEmpty(valStr))
|
|
throw new ArgumentException($"Series '{name}' has no data values. Expected format: 'Name:1,2,3'");
|
|
var vals = ParseSeriesValues(valStr, name);
|
|
result.Add((name, vals));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
for (int i = 1; i <= 20; i++)
|
|
{
|
|
// Read both keys up front so TrackingPropertyDictionary marks each
|
|
// consumed regardless of which branch supplies the values. Without
|
|
// this, `series{i}=` combined with `series{i}.name=` fell through
|
|
// to UNSUPPORTED + silent value drop (interview-edit R4 major).
|
|
var hasDotName = properties.TryGetValue($"series{i}.name", out var dotName);
|
|
var hasDotValues = properties.TryGetValue($"series{i}.values", out var dotValues);
|
|
var hasLegacy = properties.TryGetValue($"series{i}", out var legacyStr);
|
|
|
|
// Check for dotted syntax first: series1.name, series1.values
|
|
if (hasDotName || hasDotValues)
|
|
{
|
|
var name = dotName ?? $"Series {i}";
|
|
// CONSISTENCY(chart-series-mixed): when dotted .name is given
|
|
// without dotted .values, fall back to the legacy `series{i}=`
|
|
// key as the values source rather than silently dropping it.
|
|
var valuesStr = !string.IsNullOrEmpty(dotValues) ? dotValues
|
|
: (hasLegacy ? legacyStr : "");
|
|
if (!string.IsNullOrEmpty(valuesStr) && !IsRangeReference(valuesStr))
|
|
{
|
|
var vals = ParseSeriesValues(valuesStr, name);
|
|
result.Add((name, vals));
|
|
}
|
|
else
|
|
{
|
|
// Reference-based — add empty placeholder (actual ref handled by BuildChartSpace)
|
|
result.Add((name, Array.Empty<double>()));
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Legacy format: series1=Sales:10,20,30
|
|
if (!hasLegacy) continue;
|
|
var seriesStr = legacyStr!;
|
|
// CONSISTENCY(chart-series-rangeref): mirror the dotted-syntax
|
|
// guard above (line 253) — if the legacy value is a range
|
|
// reference (e.g. "Sheet1!B2:B7"), don't try to parse it as
|
|
// literal comma-separated numbers. ApplySeriesReferences picks
|
|
// it up later via the series{N} key. Without this guard
|
|
// ParseSeriesValues throws "Invalid data value 'B7'".
|
|
if (IsRangeReference(seriesStr))
|
|
{
|
|
result.Add(($"Series {i}", Array.Empty<double>()));
|
|
continue;
|
|
}
|
|
// Split on the LAST colon: values are colon-free comma numbers, so
|
|
// the rightmost colon separates a (possibly colon-containing) series
|
|
// name from its values (e.g. "Persons (Data year: 2021):1,2,3").
|
|
var colonIdx = seriesStr.LastIndexOf(':');
|
|
if (colonIdx < 0)
|
|
{
|
|
var vals = ParseSeriesValues(seriesStr, $"series{i}");
|
|
result.Add(($"Series {i}", vals));
|
|
}
|
|
else
|
|
{
|
|
var name = seriesStr[..colonIdx].Trim();
|
|
var vals = ParseSeriesValues(seriesStr[(colonIdx + 1)..], name);
|
|
result.Add((name, vals));
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parse extended series data with cell references support.
|
|
/// Returns null if no dotted syntax series found.
|
|
/// </summary>
|
|
internal static List<SeriesInfo>? ParseSeriesDataExtended(Dictionary<string, string> properties)
|
|
{
|
|
// Same flat-name alias rewrite as ParseSeriesData entry; idempotent
|
|
// when called second time.
|
|
NormalizeFlatSeriesNameAliases(properties);
|
|
|
|
var result = new List<SeriesInfo>();
|
|
|
|
for (int i = 1; i <= 20; i++)
|
|
{
|
|
var hasName = properties.TryGetValue($"series{i}.name", out var nameStr);
|
|
var hasValues = properties.TryGetValue($"series{i}.values", out var valuesStr);
|
|
// `series{N}.valuesRef=<range>` carries the source cell-range ref
|
|
// WITHOUT displacing literal values (unlike `.values=<range>`).
|
|
// The batch emitter dumps it from the Reader's per-series
|
|
// Format["valuesRef"] so dump→replay rebuilds numRef + numCache.
|
|
var hasValuesRef = properties.TryGetValue($"series{i}.valuesRef", out var valuesRefStr);
|
|
var hasCats = properties.TryGetValue($"series{i}.categories", out var catsStr);
|
|
var hasBubbleSize = properties.TryGetValue($"series{i}.bubbleSize", out var bsStr);
|
|
var hasBubbleSizeRef = properties.ContainsKey($"series{i}.bubbleSizeRef");
|
|
|
|
// CONSISTENCY(chart-series-rangeref): legacy `series{N}=Sheet1!B2:B3`
|
|
// range-ref form mirrors `series{N}.values=<range>`. ParseSeriesData
|
|
// emits an empty literal placeholder for it (line 289); pick the range
|
|
// up here as a ValuesRef so the series gets a numRef, not a blank val.
|
|
string? legacyRangeRef = null;
|
|
if (!hasValues
|
|
&& properties.TryGetValue($"series{i}", out var legacyStr)
|
|
&& !string.IsNullOrEmpty(legacyStr)
|
|
&& IsRangeReference(legacyStr))
|
|
{
|
|
legacyRangeRef = legacyStr;
|
|
}
|
|
|
|
if (!hasName && !hasValues && !hasValuesRef && !hasCats && !hasBubbleSize && !hasBubbleSizeRef && legacyRangeRef == null) continue;
|
|
|
|
var info = new SeriesInfo { Name = nameStr ?? $"Series {i}" };
|
|
|
|
if (!string.IsNullOrEmpty(valuesStr))
|
|
{
|
|
if (IsRangeReference(valuesStr))
|
|
info.ValuesRef = NormalizeRangeReference(valuesStr);
|
|
else
|
|
info.Values = ParseSeriesValues(valuesStr, info.Name);
|
|
}
|
|
else if (legacyRangeRef != null)
|
|
{
|
|
info.ValuesRef = NormalizeRangeReference(legacyRangeRef);
|
|
}
|
|
|
|
if (info.ValuesRef == null && !string.IsNullOrEmpty(valuesRefStr)
|
|
&& IsRangeReference(valuesRefStr))
|
|
{
|
|
info.ValuesRef = NormalizeRangeReference(valuesRefStr);
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(catsStr))
|
|
{
|
|
if (IsRangeReference(catsStr))
|
|
info.CategoriesRef = NormalizeRangeReference(catsStr);
|
|
}
|
|
|
|
// R52 bt-3: bubble series carry per-point sizes. `series{N}.bubbleSize`
|
|
// accepts either a comma-separated literal list or a cell range; the
|
|
// range form preserves the source <c:numRef> so PowerPoint shows the
|
|
// correct bubble pixel-geometry from the linked workbook data.
|
|
if (!string.IsNullOrEmpty(bsStr))
|
|
{
|
|
if (IsRangeReference(bsStr))
|
|
info.BubbleSizeRef = NormalizeRangeReference(bsStr);
|
|
else
|
|
info.BubbleSizeValues = ParseSeriesValues(bsStr, info.Name + ".bubbleSize");
|
|
}
|
|
// `series{N}.bubbleSizeRef` is the explicit ref key emitted by the
|
|
// batch emitter when a source <c:bubbleSize><c:numRef> is detected.
|
|
// It coexists with the literal `series{N}.bubbleSize=cached,values`
|
|
// — the ref takes precedence, the literal becomes the numCache.
|
|
if (properties.TryGetValue($"series{i}.bubbleSizeRef", out var bsRefStr)
|
|
&& !string.IsNullOrEmpty(bsRefStr))
|
|
{
|
|
info.BubbleSizeRef = NormalizeRangeReference(bsRefStr);
|
|
}
|
|
|
|
result.Add(info);
|
|
}
|
|
|
|
return result.Count > 0 ? result : null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parse the top-level categories property, supporting both literal and reference values.
|
|
/// Returns the reference string if it's a range reference, null otherwise (literal handled separately).
|
|
/// </summary>
|
|
internal static string? ParseCategoriesRef(Dictionary<string, string> properties)
|
|
{
|
|
// Explicit `categoriesRef=<range>` (the Reader/batch-emitter form:
|
|
// literal labels in `categories=` for the strCache, the source cell
|
|
// range here) wins over range-form `categories=`.
|
|
if (properties.TryGetValue("categoriesRef", out var catRefStr)
|
|
&& IsRangeReference(catRefStr))
|
|
return NormalizeRangeReference(catRefStr);
|
|
if (!properties.TryGetValue("categories", out var catStr)) return null;
|
|
if (IsRangeReference(catStr)) return NormalizeRangeReference(catStr);
|
|
return null;
|
|
}
|
|
|
|
// CONSISTENCY(chart-series-name-alias): `series{N}Name=Revenue` flat form
|
|
// → `series{N}.name=Revenue` dotted form. Records the read on the
|
|
// original flat key (via TryGetValue) so handler-as-truth tracking still
|
|
// marks the user input consumed.
|
|
private static void NormalizeFlatSeriesNameAliases(Dictionary<string, string> properties)
|
|
{
|
|
for (int i = 1; i <= 20; i++)
|
|
{
|
|
var flatKey = $"series{i}Name";
|
|
if (properties.TryGetValue(flatKey, out var nameVal))
|
|
{
|
|
var dottedKey = $"series{i}.name";
|
|
if (!properties.ContainsKey(dottedKey))
|
|
properties[dottedKey] = nameVal;
|
|
// Remove the flat key so SetChartProperties' dispatch loop
|
|
// doesn't also iterate it — the legacy `series{N}=` branch
|
|
// does int.TryParse on the suffix ("2Name") and reports it
|
|
// as unsupported. TryGetValue above already marked the
|
|
// original input consumed for handler-as-truth tracking.
|
|
properties.Remove(flatKey);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static double[] ParseSeriesValues(string valStr, string seriesName)
|
|
{
|
|
return valStr.Split(',').Select(v =>
|
|
{
|
|
var trimmed = v.Trim();
|
|
if (!double.TryParse(trimmed, System.Globalization.CultureInfo.InvariantCulture, out var num)
|
|
|| double.IsNaN(num) || double.IsInfinity(num))
|
|
throw new ArgumentException($"Invalid data value '{trimmed}' in series '{seriesName}'. Expected comma-separated finite numbers (e.g. '1,2,3').");
|
|
return num;
|
|
}).ToArray();
|
|
}
|
|
|
|
internal static string[]? ParseCategories(Dictionary<string, string> properties)
|
|
{
|
|
if (!properties.TryGetValue("categories", out var catStr)) return null;
|
|
// If the value is a cell range reference, don't treat as literal categories
|
|
if (IsRangeReference(catStr)) return null;
|
|
return catStr.Split(',').Select(c => c.Trim()).ToArray();
|
|
}
|
|
|
|
// BUG-DUMP-R36-3: series indices (0-based) whose dump carried per-point
|
|
// <c:dPt> styling (and/or a verbatim series <c:spPr>) but NO series-level
|
|
// fill key (series{N}.color / .gradient / .spPr). For these the source had
|
|
// no series-level <c:spPr>; the chart builder must therefore NOT inject the
|
|
// Office accent1 default — doing so plants a spurious solidFill that a
|
|
// partial-dPt series would visibly show. Mirrors the spec "only emit a
|
|
// series spPr when one was actually captured". Fresh `add chart` (no dPt
|
|
// keys) is unaffected and keeps its default palette.
|
|
internal static HashSet<int> SeriesWithNoCapturedFill(Dictionary<string, string> properties)
|
|
{
|
|
var result = new HashSet<int>();
|
|
foreach (var kv in properties)
|
|
{
|
|
var k = kv.Key;
|
|
if (!k.StartsWith("series", StringComparison.OrdinalIgnoreCase)) continue;
|
|
int dot = k.IndexOf('.');
|
|
if (dot <= 6) continue;
|
|
var mid = k.Substring(6, dot - 6);
|
|
if (!int.TryParse(mid, out var idx1) || idx1 < 1) continue;
|
|
var sub = k.Substring(dot + 1);
|
|
// A series carrying verbatim per-point styling (dPt) or a verbatim
|
|
// series spPr is the replay signal. We only flag it for default
|
|
// suppression when it does NOT also carry a series-level fill key.
|
|
if (!sub.Equals("dPt", StringComparison.OrdinalIgnoreCase)
|
|
&& !sub.Equals("spPr", StringComparison.OrdinalIgnoreCase)
|
|
&& !sub.Equals("inheritFill", StringComparison.OrdinalIgnoreCase)) continue;
|
|
// `series{N}.inheritFill=true`: the dump saw a source series with
|
|
// no <c:spPr> at all — the fill is theme-inherited, so the replay
|
|
// must not pin a DefaultSeriesColors palette entry over it. The
|
|
// TryGetValue registers the key as consumed (handler-as-truth
|
|
// tracking doesn't fire on Dictionary-typed foreach iteration).
|
|
if (sub.Equals("inheritFill", StringComparison.OrdinalIgnoreCase))
|
|
properties.TryGetValue(k, out _);
|
|
int idx0 = idx1 - 1;
|
|
// A series{N}.spPr key IS a captured series fill — keep it (the
|
|
// verbatim spPr is applied post-build), so do not flag spPr-bearing
|
|
// series. Only dPt-only series (no series-level fill) get flagged.
|
|
if (sub.Equals("spPr", StringComparison.OrdinalIgnoreCase)) continue;
|
|
bool hasSeriesFill =
|
|
properties.ContainsKey($"series{idx1}.color")
|
|
|| properties.ContainsKey($"series{idx1}.gradient")
|
|
|| properties.ContainsKey($"series{idx1}.spPr");
|
|
if (!hasSeriesFill) result.Add(idx0);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
internal static string[]? ParseSeriesColors(Dictionary<string, string> properties)
|
|
{
|
|
// CONSISTENCY(chart-series-color): Add path accepts both the
|
|
// compact `colors=red,blue,green` form and per-series dotted
|
|
// `series{N}.color=<hex>` keys (same vocabulary that `set chart`
|
|
// already supports via ApplySeriesColor). When both are supplied,
|
|
// dotted keys override positions in the `colors` array.
|
|
string[]? arr = null;
|
|
if (properties.TryGetValue("colors", out var colorsStr) || properties.TryGetValue("seriesColors", out colorsStr))
|
|
arr = colorsStr.Split(',').Select(c => c.Trim()).ToArray();
|
|
|
|
// Collect per-series dotted color keys
|
|
var dotted = new Dictionary<int, string>();
|
|
foreach (var kv in properties)
|
|
{
|
|
var k = kv.Key;
|
|
if (!k.StartsWith("series", StringComparison.OrdinalIgnoreCase)) continue;
|
|
if (!k.EndsWith(".color", StringComparison.OrdinalIgnoreCase)) continue;
|
|
var mid = k.Substring(6, k.Length - 6 - ".color".Length);
|
|
if (!int.TryParse(mid, out var idx) || idx < 1) continue;
|
|
// Mark this series{N}.color key consumed. A `foreach` over a
|
|
// Dictionary<>-typed parameter binds to the base struct enumerator,
|
|
// which bypasses TrackingPropertyDictionary's consumption tracking
|
|
// (only TryGetValue/ContainsKey/indexer route through the recording
|
|
// comparer). Without this ContainsKey, the Add path reports an
|
|
// applied series color as UNSUPPORTED and exits non-zero.
|
|
_ = properties.ContainsKey(k);
|
|
if (!string.IsNullOrWhiteSpace(kv.Value))
|
|
dotted[idx] = kv.Value.Trim();
|
|
}
|
|
if (dotted.Count == 0) return arr;
|
|
|
|
var maxIdx = dotted.Keys.Max();
|
|
var size = Math.Max(maxIdx, arr?.Length ?? 0);
|
|
var merged = new string[size];
|
|
for (int i = 0; i < size; i++)
|
|
{
|
|
if (dotted.TryGetValue(i + 1, out var c))
|
|
merged[i] = c;
|
|
else if (arr != null && i < arr.Length && !string.IsNullOrEmpty(arr[i]))
|
|
merged[i] = arr[i];
|
|
else
|
|
merged[i] = DefaultSeriesColors[i % DefaultSeriesColors.Length];
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Like ParseSeriesColors but returns ONLY the per-series dotted color
|
|
/// keys (`series{N}.color=<hex>`), without merging the positional
|
|
/// `colors=` array. Used by single-series chart builders
|
|
/// (pie/doughnut/stock) where positional `colors=` is per-data-point but
|
|
/// `series{N}.color` should still tint the whole series.
|
|
/// </summary>
|
|
internal static Dictionary<int, string> ParseDottedSeriesColorsOnly(Dictionary<string, string> properties)
|
|
{
|
|
var dotted = new Dictionary<int, string>();
|
|
foreach (var kv in properties)
|
|
{
|
|
var k = kv.Key;
|
|
if (!k.StartsWith("series", StringComparison.OrdinalIgnoreCase)) continue;
|
|
if (!k.EndsWith(".color", StringComparison.OrdinalIgnoreCase)) continue;
|
|
var mid = k.Substring(6, k.Length - 6 - ".color".Length);
|
|
if (!int.TryParse(mid, out var idx) || idx < 1) continue;
|
|
// Mark consumed — see ParseSeriesColors: foreach over a
|
|
// Dictionary<>-typed param bypasses the tracking enumerator.
|
|
_ = properties.ContainsKey(k);
|
|
if (!string.IsNullOrWhiteSpace(kv.Value))
|
|
dotted[idx] = kv.Value.Trim();
|
|
}
|
|
return dotted;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Positional-only `colors=` / `seriesColors=` array, returned unmerged.
|
|
/// Used by pie / doughnut / pieofpie / barofpie / stock builders where the
|
|
/// positional list maps to per-data-point dPt overrides, but `series{N}.
|
|
/// color` MUST NOT bleed into the dPt list (it routes through
|
|
/// ApplyDottedSeriesColors as a series-level fill instead). The merged
|
|
/// array from ParseSeriesColors conflates the two, so a doughnut with
|
|
/// only `series1.color=#C00000` emitted a spurious dPt#0 with the same
|
|
/// fill — Get readback then surfaced both `series1.color` AND
|
|
/// `point1.color`, breaking dump→replay byte-equality.
|
|
/// </summary>
|
|
internal static string[]? ParsePositionalColorsOnly(Dictionary<string, string> properties)
|
|
{
|
|
if (properties.TryGetValue("colors", out var colorsStr)
|
|
|| properties.TryGetValue("seriesColors", out colorsStr))
|
|
return colorsStr.Split(',').Select(c => c.Trim()).ToArray();
|
|
return null;
|
|
}
|
|
|
|
// ==================== ManualLayout Helpers ====================
|
|
|
|
/// <summary>
|
|
/// Ensures the given element has a Layout > ManualLayout child and sets the specified
|
|
/// positional property (x/y/w/h). Creates Layout and ManualLayout if missing.
|
|
/// For plotArea, LayoutTarget is set to Inner; for others it is omitted.
|
|
/// </summary>
|
|
internal static void SetManualLayoutProperty(OpenXmlCompositeElement parent, string prop, double value, bool isPlotArea = false)
|
|
{
|
|
var layout = parent.GetFirstChild<C.Layout>();
|
|
if (layout == null)
|
|
{
|
|
layout = new C.Layout();
|
|
// Insert layout after structural elements to respect schema order.
|
|
// c:title → tx, [layout], overlay, ...
|
|
// c:legend → legendPos, legendEntry*, [layout], overlay, ...
|
|
// c:dLbl → idx, delete, [layout], ...
|
|
// c:plotArea → layout is first child (InsertAt 0 is correct)
|
|
if (isPlotArea)
|
|
{
|
|
parent.InsertAt(layout, 0);
|
|
}
|
|
else if (parent is C.DataLabel)
|
|
{
|
|
// CT_DLbl: idx, delete, [layout], tx, numFmt, spPr, ...
|
|
var insertAfter = parent.GetFirstChild<C.Delete>() as OpenXmlElement
|
|
?? parent.GetFirstChild<C.Index>() as OpenXmlElement;
|
|
if (insertAfter != null)
|
|
insertAfter.InsertAfterSelf(layout);
|
|
else
|
|
parent.InsertAt(layout, 0);
|
|
}
|
|
else
|
|
{
|
|
// c:title → tx, [layout], overlay, ...
|
|
// c:legend → legendPos, legendEntry*, [layout], overlay, ...
|
|
var insertAfter = parent.GetFirstChild<C.ChartText>() as OpenXmlElement
|
|
?? parent.ChildElements.LastOrDefault(
|
|
e => e.LocalName is "legendPos" or "legendEntry") as OpenXmlElement;
|
|
if (insertAfter != null)
|
|
insertAfter.InsertAfterSelf(layout);
|
|
else
|
|
parent.InsertAt(layout, 0);
|
|
}
|
|
}
|
|
var ml = layout.GetFirstChild<C.ManualLayout>();
|
|
if (ml == null)
|
|
{
|
|
ml = new C.ManualLayout();
|
|
if (isPlotArea)
|
|
ml.LayoutTarget = new C.LayoutTarget { Val = C.LayoutTargetValues.Inner };
|
|
ml.LeftMode = new C.LeftMode { Val = C.LayoutModeValues.Edge };
|
|
ml.TopMode = new C.TopMode { Val = C.LayoutModeValues.Edge };
|
|
layout.AppendChild(ml);
|
|
}
|
|
// Use typed properties to guarantee schema order (OneSequence)
|
|
switch (prop)
|
|
{
|
|
case "x": ml.Left = new C.Left { Val = value }; break;
|
|
case "y": ml.Top = new C.Top { Val = value }; break;
|
|
case "w": ml.Width = new C.Width { Val = value }; break;
|
|
case "h": ml.Height = new C.Height { Val = value }; break;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Read ManualLayout x/y/w/h from an element that has Layout as a child.
|
|
/// Writes results into node.Format with the given prefix (e.g. "plotArea", "title", "legend").
|
|
/// </summary>
|
|
internal static void ReadManualLayout(OpenXmlCompositeElement parent, DocumentNode node, string prefix)
|
|
{
|
|
var layout = parent.GetFirstChild<C.Layout>();
|
|
var ml = layout?.GetFirstChild<C.ManualLayout>();
|
|
if (ml == null) return;
|
|
|
|
var x = ml.Left?.Val?.Value;
|
|
var y = ml.Top?.Val?.Value;
|
|
var w = ml.Width?.Val?.Value;
|
|
var h = ml.Height?.Val?.Value;
|
|
|
|
if (x != null) node.Format[$"{prefix}.x"] = x.Value.ToString("0.######", System.Globalization.CultureInfo.InvariantCulture);
|
|
if (y != null) node.Format[$"{prefix}.y"] = y.Value.ToString("0.######", System.Globalization.CultureInfo.InvariantCulture);
|
|
if (w != null) node.Format[$"{prefix}.w"] = w.Value.ToString("0.######", System.Globalization.CultureInfo.InvariantCulture);
|
|
if (h != null) node.Format[$"{prefix}.h"] = h.Value.ToString("0.######", System.Globalization.CultureInfo.InvariantCulture);
|
|
}
|
|
}
|