mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-27 12:16:54 -06:00
Charts: PivotCharts (insert.pivotChart) (#311)
A chart linked to a PivotTable: series are built from the report (innermost row items x column items/value fields, totals excluded) and rebuilt on every refresh or layout change; c:pivotSource round trip; chart series accept union references. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
9f896c1249
commit
1a7c16eb9a
@@ -131,6 +131,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-11 | PivotCharts (`insert.pivotChart`): charts bound to a PivotTable's report, following refreshes and layout changes (#311) |
|
||||
| 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed |
|
||||
| 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 |
|
||||
| 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows |
|
||||
|
||||
@@ -46,12 +46,54 @@ fn parse_ref(wb: &Workbook, sheet: usize, formula: &str) -> Option<(usize, Range
|
||||
Some((sh, r))
|
||||
}
|
||||
|
||||
/// Evaluates a series formula to a flat list of values (row-major), capped.
|
||||
/// The parts of a union reference `(Sheet1!$A$1:$A$3,Sheet1!$A$5)`, or `None`.
|
||||
fn union_parts(f: &str) -> Option<Vec<&str>> {
|
||||
let inner = f.strip_prefix('(')?.strip_suffix(')')?;
|
||||
let mut parts = Vec::new();
|
||||
let (mut depth, mut quoted, mut start) = (0i32, false, 0usize);
|
||||
for (i, c) in inner.char_indices() {
|
||||
match c {
|
||||
'\'' => quoted = !quoted,
|
||||
'(' if !quoted => depth += 1,
|
||||
')' if !quoted => depth -= 1,
|
||||
',' if !quoted && depth == 0 => {
|
||||
parts.push(inner.get(start..i)?);
|
||||
start = i + 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if depth < 0 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
parts.push(inner.get(start..)?);
|
||||
(parts.len() > 1).then_some(parts)
|
||||
}
|
||||
|
||||
/// Evaluates a series formula to a flat list of values (row-major), capped. A union of
|
||||
/// references (`(A1:A3,A5)`, as PivotCharts use) is read part by part.
|
||||
fn eval_list(wb: &Workbook, sheet: usize, formula: &str) -> Vec<Value> {
|
||||
eval_list_in(wb, sheet, formula, 0)
|
||||
}
|
||||
|
||||
fn eval_list_in(wb: &Workbook, sheet: usize, formula: &str, depth: u32) -> Vec<Value> {
|
||||
let f = formula.trim();
|
||||
if f.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
if depth < 4
|
||||
&& let Some(parts) = union_parts(f)
|
||||
{
|
||||
let mut out = Vec::new();
|
||||
for p in parts {
|
||||
if out.len() >= MAX_POINTS {
|
||||
break;
|
||||
}
|
||||
let rest = MAX_POINTS - out.len();
|
||||
out.extend(eval_list_in(wb, sheet, p, depth + 1).into_iter().take(rest));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
match evaluate(wb, sheet, CellRef::default(), f) {
|
||||
Value::Array(a) => a.iter().take(MAX_POINTS).cloned().collect(),
|
||||
v => vec![v],
|
||||
|
||||
@@ -48,6 +48,7 @@ fn chart(kind: ChartKind) -> Chart {
|
||||
y_title: Some("Revenue".into()),
|
||||
source: None,
|
||||
by_rows: false,
|
||||
pivot: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -658,3 +659,29 @@ fn treemap_and_histogram_shapes() {
|
||||
let bins = prims.iter().filter(|p| matches!(p, Prim::Rect { fill: Some([10, 20, 30, 255]), .. })).count();
|
||||
assert_eq!(bins, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn union_series_references() {
|
||||
let mut wb = Workbook::new();
|
||||
{
|
||||
let s = wb.sheet_mut(0).unwrap();
|
||||
for (r, (l, v)) in [("a", 1.0), ("b", 2.0), ("Total", 3.0), ("d", 4.0)].iter().enumerate() {
|
||||
s.set_value(CellRef::new(r as u32, 0), Value::from(*l));
|
||||
s.set_value(CellRef::new(r as u32, 1), Value::Number(*v));
|
||||
}
|
||||
}
|
||||
let mut c = chart(ChartKind::ColumnClustered);
|
||||
c.series = vec![Series {
|
||||
name: Some("\"Sum of X\"".into()),
|
||||
categories: Some("(Sheet1!$A$1:$A$2,Sheet1!$A$4)".into()),
|
||||
values: "(Sheet1!$B$1:$B$2,Sheet1!$B$4)".into(),
|
||||
..Series::default()
|
||||
}];
|
||||
let d = resolve(&wb, 0, &c);
|
||||
assert_eq!(d.series[0].values, vec![Some(1.0), Some(2.0), Some(4.0)]);
|
||||
assert_eq!(d.series[0].name, "Sum of X");
|
||||
assert_eq!(d.categories, vec!["a", "b", "d"]);
|
||||
// Deeply nested parentheses from a file are bounded, not a crash.
|
||||
c.series[0].values = format!("{}Sheet1!$B$1,Sheet1!$B$2{}", "(".repeat(5000), ")".repeat(5000));
|
||||
let _ = resolve(&wb, 0, &c);
|
||||
}
|
||||
|
||||
@@ -606,6 +606,7 @@ fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
y_title: None,
|
||||
source: Some(format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), r.a1())),
|
||||
by_rows,
|
||||
pivot: None,
|
||||
};
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?.charts.push(chart);
|
||||
|
||||
@@ -43,6 +43,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
summarize_table
|
||||
),
|
||||
cmd!(
|
||||
"insert.pivotChart",
|
||||
"PivotChart",
|
||||
["Insert", "Charts"],
|
||||
None,
|
||||
"{pivot?: name | id | a cell of the report (\"Sheet2!B5\"; default: the PivotTable at the active cell), type?: column|bar|line|pie|area|… (default column), subtype?, title?, at?: \"H2\", width?, height?} adds a chart bound to the PivotTable's report: categories are the innermost row items, series the column items × value fields; it follows refreshes and layout changes → {chart, pivot, series}",
|
||||
has_doc,
|
||||
insert_pivot_chart
|
||||
),
|
||||
cmd!(query "pivot.fields", "PivotTable Fields", [], None, "{pivot?} → source fields (name, numeric, date, samples, areas) and the current layout", has_doc, fields),
|
||||
cmd!(query "pivot.fieldList", "Field List", ["PivotTable Analyze", "Show"], None, "{pivot?} (same as pivot.fields)", has_doc, fields),
|
||||
cmd!(query "pivot.list", "PivotTables", [], None, "{} → every PivotTable in the workbook", has_doc, list),
|
||||
@@ -1171,6 +1180,18 @@ fn change_source(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
fn delete(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (si, pi) = find_pivot(s, p)?;
|
||||
edit(s, |cx| {
|
||||
// Its PivotCharts become ordinary charts.
|
||||
let link = cx.wb.sheet(si).and_then(|sh| sh.pivots.get(pi).map(|pt| (sh.name.clone(), pt.name.clone())));
|
||||
if let Some((sheet, name)) = link {
|
||||
for i in 0..cx.wb.sheets.len() {
|
||||
let linked = |c: &Chart| c.pivot.as_deref().is_some_and(|l| pv::links_to(l, &sheet, &name));
|
||||
if cx.wb.sheet(i).is_some_and(|sh| sh.charts.iter().any(linked)) {
|
||||
for c in cx.sheet_mut(i)?.charts.iter_mut().filter(|c| linked(c)) {
|
||||
c.pivot = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pv::clear_output(&mut cx.wb, si, pi);
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
if pi < sh.pivots.len() {
|
||||
@@ -1181,6 +1202,80 @@ fn delete(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- PivotChart
|
||||
|
||||
/// The PivotTable a PivotChart call targets: `pivot` may also be a cell of its report.
|
||||
fn chart_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> {
|
||||
if let Some(t) = str_param(p, "pivot") {
|
||||
let d = s.doc()?;
|
||||
let (sheet, addr) = match t.rsplit_once('!') {
|
||||
Some((sh, a)) => {
|
||||
let sh = sh.trim();
|
||||
let sh = sh.strip_prefix('\'').and_then(|x| x.strip_suffix('\'')).map(|x| x.replace("''", "'")).unwrap_or_else(|| sh.to_string());
|
||||
(d.wb.sheet_index(&sh), a)
|
||||
}
|
||||
None => (Some(d.wb.active_sheet), t),
|
||||
};
|
||||
if let (Some(si), Some(c)) = (sheet, CellRef::parse(addr.trim().replace('$', "").as_str())) {
|
||||
let hit = d.wb.sheet(si).and_then(|sh| sh.pivots.iter().position(|pt| pt.last_range.is_some_and(|r| r.contains(c)) || pt.anchor == c));
|
||||
return hit.map(|pi| (si, pi)).ok_or_else(|| bad("insert.pivotChart", format!("{t} is not in a PivotTable")));
|
||||
}
|
||||
}
|
||||
find_pivot(s, p)
|
||||
}
|
||||
|
||||
fn insert_pivot_chart(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (si, pi) = chart_pivot(s, p)?;
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
|
||||
let pt = sh.pivots.get(pi).ok_or_else(|| other("There's no such PivotTable."))?;
|
||||
let area = pt.last_range.ok_or_else(|| other("Refresh the PivotTable before adding a PivotChart."))?;
|
||||
let out = pv::compute(&d.wb, pt, si).map_err(other)?;
|
||||
if out.chart_rows.is_empty() || out.chart_cols.is_empty() {
|
||||
return Err(other("Add a field to the Values area and one to Rows or Columns before creating a PivotChart."));
|
||||
}
|
||||
let series = pv::chart_series(&sh.name, area.start, &out);
|
||||
let t = str_param(p, "type").unwrap_or("column");
|
||||
let kind = super::insert::chart_kind(t, str_param(p, "subtype").unwrap_or(""));
|
||||
let at = cell_param(p, "at").unwrap_or_else(|| CellRef::new(area.start.row, area.end.col.saturating_add(2).min(gridcraft_core::MAX_COLS - 1)));
|
||||
let title = match p.get("title") {
|
||||
Some(Json::String(t)) => Some(t.clone()),
|
||||
Some(Json::Null) => None,
|
||||
_ => Some(if series.len() == 1 { "Total".to_string() } else { "Chart Title".to_string() }),
|
||||
};
|
||||
let id = d.wb.next_object_id();
|
||||
let link = pv::chart_link(&sh.name, &pt.name);
|
||||
let pivot_name = pt.name.clone();
|
||||
let n = series.len();
|
||||
let chart = Chart {
|
||||
id,
|
||||
kind,
|
||||
anchor: Anchor {
|
||||
cell: at,
|
||||
dx: 0.0,
|
||||
dy: 0.0,
|
||||
width: f64_param(p, "width").unwrap_or(480.0).clamp(10.0, 10_000.0) as f32,
|
||||
height: f64_param(p, "height").unwrap_or(288.0).clamp(10.0, 10_000.0) as f32,
|
||||
mode: anchor_mode_param(p).unwrap_or_default(),
|
||||
},
|
||||
title,
|
||||
series,
|
||||
legend: if matches!(kind, ChartKind::Pie | ChartKind::Doughnut) { LegendPos::Right } else { LegendPos::Bottom },
|
||||
data_labels: false,
|
||||
gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel),
|
||||
style: 1,
|
||||
x_title: None,
|
||||
y_title: None,
|
||||
source: None,
|
||||
by_rows: false,
|
||||
pivot: Some(link),
|
||||
};
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(si)?.charts.push(chart);
|
||||
Ok(json!({"chart": id, "pivot": pivot_name, "series": n}))
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- recommended
|
||||
|
||||
fn suggestions(wb: &Workbook, src: &pv::Source) -> Vec<Json> {
|
||||
@@ -1550,6 +1645,76 @@ mod tests {
|
||||
s.execute("pivot.refresh", json!({"pivot": "PivotTable1"})).unwrap();
|
||||
}
|
||||
|
||||
fn pivot_chart(s: &Session) -> gridcraft_model::Chart {
|
||||
s.doc().unwrap().wb.active().unwrap().charts.last().unwrap().clone()
|
||||
}
|
||||
fn plotted(s: &Session) -> Vec<Vec<Option<f64>>> {
|
||||
let d = s.doc().unwrap();
|
||||
let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet, &pivot_chart(s));
|
||||
data.series.into_iter().map(|x| x.values).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pivot_chart_follows_the_report() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap();
|
||||
// Any cell of the report picks the PivotTable.
|
||||
let r = s.execute("insert.pivotChart", json!({"pivot": "B5"})).unwrap();
|
||||
assert_eq!(r["series"], 1);
|
||||
let ch = pivot_chart(&s);
|
||||
assert_eq!(ch.pivot.as_deref(), Some("Sheet2!PivotTable1"));
|
||||
assert_eq!(ch.title.as_deref(), Some("Total"));
|
||||
assert_eq!(ch.series[0].values, "Sheet2!$B$4:$B$6");
|
||||
assert_eq!(ch.series[0].categories.as_deref(), Some("Sheet2!$A$4:$A$6"));
|
||||
assert_eq!(plotted(&s), vec![vec![Some(415.0), Some(135.0), Some(285.0)]]);
|
||||
// A column field: one series per item, rebound by the layout change.
|
||||
s.execute("pivot.addField", json!({"field": "Product", "area": "columns"})).unwrap();
|
||||
let ch = pivot_chart(&s);
|
||||
let names: Vec<_> = ch.series.iter().map(|x| x.name.clone().unwrap_or_default()).collect();
|
||||
assert_eq!(names, vec!["\"Apples\"", "\"Pears\"", "\"Plums\""]);
|
||||
assert_eq!(plotted(&s)[0], vec![Some(160.0), Some(90.0), Some(170.0)]);
|
||||
// Nested row fields: the innermost items only, as a union around the parent rows.
|
||||
s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap();
|
||||
let ch = pivot_chart(&s);
|
||||
assert!(ch.series[0].values.starts_with('('), "{}", ch.series[0].values);
|
||||
let pts = plotted(&s);
|
||||
assert_eq!(pts[0].len(), 9);
|
||||
assert_eq!(pts[0].iter().flatten().sum::<f64>(), TOTAL);
|
||||
// Survives an XLSX round trip as a PivotChart.
|
||||
let wb = (*s.doc().unwrap().wb).clone();
|
||||
let (back, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&wb).unwrap()).unwrap();
|
||||
let si = back.sheet_index("Sheet2").unwrap();
|
||||
assert_eq!(back.sheet(si).unwrap().charts.first().and_then(|c| c.pivot.clone()).as_deref(), Some("Sheet2!PivotTable1"));
|
||||
// Renaming the sheet keeps the link; a refresh still rebinds.
|
||||
s.execute("sheet.rename", json!({"name": "Report"})).unwrap();
|
||||
assert_eq!(pivot_chart(&s).pivot.as_deref(), Some("Report!PivotTable1"));
|
||||
s.execute("pivot.moveField", json!({"field": "Region", "from": "rows", "to": "columns"})).unwrap();
|
||||
assert_eq!(pivot_chart(&s).series.len(), 3);
|
||||
// Deleting the PivotTable leaves an ordinary chart.
|
||||
s.execute("pivot.delete", json!({})).unwrap();
|
||||
assert_eq!(pivot_chart(&s).pivot, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pivot_chart_errors() {
|
||||
let mut s = sales();
|
||||
// No PivotTable yet.
|
||||
assert!(s.execute("insert.pivotChart", json!({})).is_err());
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Region"]})).unwrap();
|
||||
// Not in the report; no values.
|
||||
assert!(s.execute("insert.pivotChart", json!({"pivot": "Z99"})).is_err());
|
||||
assert!(s.execute("insert.pivotChart", json!({})).unwrap_err().to_string().contains("Values"));
|
||||
s.execute("pivot.addField", json!({"field": "Units", "area": "values"})).unwrap();
|
||||
let r = s.execute("insert.pivotChart", json!({"pivot": "PivotTable1", "type": "line", "title": null})).unwrap();
|
||||
assert_eq!(r["series"], 1);
|
||||
let ch = pivot_chart(&s);
|
||||
assert_eq!(ch.kind, gridcraft_model::ChartKind::Line);
|
||||
assert_eq!(ch.title, None);
|
||||
// One undo step.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().charts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_protection() {
|
||||
let mut s = sales();
|
||||
|
||||
@@ -538,6 +538,12 @@ fn rename_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
se.categories = se.categories.as_deref().map(fix);
|
||||
se.name = se.name.as_deref().map(fix);
|
||||
}
|
||||
// A PivotChart follows its PivotTable's sheet.
|
||||
if let Some((sheet, pivot)) = ch.pivot.as_deref().and_then(crate::pivot::parse_chart_link)
|
||||
&& sheet.eq_ignore_ascii_case(&old)
|
||||
{
|
||||
ch.pivot = Some(crate::pivot::chart_link(&name, &pivot));
|
||||
}
|
||||
}
|
||||
}
|
||||
for n in cx.wb.names.iter_mut() {
|
||||
|
||||
+163
-7
@@ -503,6 +503,12 @@ pub struct Output {
|
||||
/// Rows above the body (report filters plus a blank row).
|
||||
pub body_offset: u32,
|
||||
pub width: u32,
|
||||
/// Grid rows of the innermost (or collapsed) items: a PivotChart's categories.
|
||||
pub chart_rows: Vec<u32>,
|
||||
/// Grid columns of item (not total) values with their series names: a PivotChart's series.
|
||||
pub chart_cols: Vec<(u32, String)>,
|
||||
/// Grid column holding the innermost row labels (`None` without row fields).
|
||||
pub label_col: Option<u32>,
|
||||
}
|
||||
|
||||
impl Output {
|
||||
@@ -546,6 +552,8 @@ enum LineKind {
|
||||
|
||||
struct Line {
|
||||
kind: LineKind,
|
||||
/// A category of a PivotChart: an innermost (or collapsed) item.
|
||||
leaf: bool,
|
||||
path: Vec<u32>,
|
||||
values: bool,
|
||||
/// (column, value, indent, number format)
|
||||
@@ -587,14 +595,21 @@ impl Ctx<'_> {
|
||||
let (col, indent) = if outline { (depth, 0) } else { (0, depth.min(250) as u8) };
|
||||
let collapsed = depth + 1 < n && self.rows.get(depth).is_some_and(|f| is_collapsed(f, &label));
|
||||
if depth + 1 >= n || collapsed || node.children.is_empty() {
|
||||
out.push(Line { kind: LineKind::Item(depth + 1 < n), path: prefix.clone(), values: true, labels: vec![(col, v, indent, fmt)] });
|
||||
out.push(Line {
|
||||
kind: LineKind::Item(depth + 1 < n),
|
||||
leaf: true,
|
||||
path: prefix.clone(),
|
||||
values: true,
|
||||
labels: vec![(col, v, indent, fmt)],
|
||||
});
|
||||
} else {
|
||||
let top = self.pt.subtotals && self.pt.subtotals_top;
|
||||
out.push(Line { kind: LineKind::Item(true), path: prefix.clone(), values: top, labels: vec![(col, v, indent, fmt)] });
|
||||
out.push(Line { kind: LineKind::Item(true), leaf: false, path: prefix.clone(), values: top, labels: vec![(col, v, indent, fmt)] });
|
||||
self.flatten(&node.children, depth + 1, prefix, out);
|
||||
if self.pt.subtotals && !self.pt.subtotals_top {
|
||||
out.push(Line {
|
||||
kind: LineKind::Subtotal,
|
||||
leaf: false,
|
||||
path: prefix.clone(),
|
||||
values: true,
|
||||
labels: vec![(col, Value::text(format!("{label} Total")), indent, None)],
|
||||
@@ -621,12 +636,13 @@ impl Ctx<'_> {
|
||||
labels.push((depth, v, 0, fmt));
|
||||
let collapsed = depth + 1 < n && self.rows.get(depth).is_some_and(|f| is_collapsed(f, &label));
|
||||
if depth + 1 >= n || collapsed || node.children.is_empty() {
|
||||
out.push(Line { kind: LineKind::Item(false), path: prefix.clone(), values: true, labels });
|
||||
out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels });
|
||||
} else {
|
||||
self.flatten_tabular(&node.children, depth + 1, prefix, &labels, out);
|
||||
if self.pt.subtotals {
|
||||
out.push(Line {
|
||||
kind: LineKind::Subtotal,
|
||||
leaf: false,
|
||||
path: prefix.clone(),
|
||||
values: true,
|
||||
labels: vec![(depth, Value::text(format!("{label} Total")), 0, None)],
|
||||
@@ -755,7 +771,7 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
||||
let ctx = Ctx { pt, rows: row_fields.clone(), row_items, row_fmts: row_fmts.clone() };
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
if rows.is_empty() {
|
||||
lines.push(Line { kind: LineKind::Item(false), path: vec![], values: true, labels: vec![] });
|
||||
lines.push(Line { kind: LineKind::Item(false), leaf: true, path: vec![], values: true, labels: vec![] });
|
||||
} else {
|
||||
if pt.layout == PivotLayout::Tabular {
|
||||
ctx.flatten_tabular(&row_tree, 0, &mut Vec::new(), &[], &mut lines);
|
||||
@@ -763,7 +779,13 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
||||
ctx.flatten(&row_tree, 0, &mut Vec::new(), &mut lines);
|
||||
}
|
||||
if pt.grand_totals_cols {
|
||||
lines.push(Line { kind: LineKind::Grand, path: vec![], values: true, labels: vec![(0, Value::text("Grand Total"), 0, None)] });
|
||||
lines.push(Line {
|
||||
kind: LineKind::Grand,
|
||||
leaf: false,
|
||||
path: vec![],
|
||||
values: true,
|
||||
labels: vec![(0, Value::text("Grand Total"), 0, None)],
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut specs: Vec<ColSpec> = Vec::new();
|
||||
@@ -829,7 +851,15 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
||||
if empty {
|
||||
let r = g.push_row(RowKind::Placeholder)?;
|
||||
g.set(r, 0, Value::text(format!("{} — choose fields", pt.name)), CellKind::Placeholder, 0, None);
|
||||
return Ok(Output { width: g.width as u32, grid: g.rows, row_kinds: g.kinds, body_offset });
|
||||
return Ok(Output {
|
||||
width: g.width as u32,
|
||||
grid: g.rows,
|
||||
row_kinds: g.kinds,
|
||||
body_offset,
|
||||
chart_rows: vec![],
|
||||
chart_cols: vec![],
|
||||
label_col: None,
|
||||
});
|
||||
}
|
||||
|
||||
let val_name = |vi: usize| vals.get(vi).map(|(_, v)| v.name.clone()).unwrap_or_default();
|
||||
@@ -949,6 +979,33 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
||||
}),
|
||||
})
|
||||
.collect();
|
||||
let mut chart_rows: Vec<u32> = Vec::new();
|
||||
let chart_cols: Vec<(u32, String)> = data_cols
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(j, (si, vi))| {
|
||||
let spec = specs.get(*si)?;
|
||||
if spec.kind != ColKind::Leaf {
|
||||
return None;
|
||||
}
|
||||
let mut parts: Vec<String> =
|
||||
spec.path.iter().enumerate().map(|(lv, it)| col_items.get(lv).map(|fi| fi.label(*it)).unwrap_or_default()).collect();
|
||||
if let Some(vi) = vi
|
||||
&& (k > 1 || parts.is_empty())
|
||||
{
|
||||
parts.push(val_name(*vi));
|
||||
}
|
||||
Some(((l + j) as u32, parts.join(" - ")))
|
||||
})
|
||||
.filter(|_| k > 0)
|
||||
.collect();
|
||||
let label_col = if rows.is_empty() {
|
||||
None
|
||||
} else if compact {
|
||||
Some(0)
|
||||
} else {
|
||||
Some(rows.len().saturating_sub(1) as u32)
|
||||
};
|
||||
for line in &lines {
|
||||
let rk = match line.kind {
|
||||
LineKind::Item(b) => RowKind::Item(b),
|
||||
@@ -961,6 +1018,9 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
||||
LineKind::Grand => (CellKind::GrandTotal, CellKind::GrandTotal),
|
||||
};
|
||||
let r = g.push_row(rk)?;
|
||||
if line.leaf && k > 0 {
|
||||
chart_rows.push(r as u32);
|
||||
}
|
||||
for (c, v, indent, fmt) in &line.labels {
|
||||
g.set(r, *c, v.clone(), ck.0, *indent, fmt.clone());
|
||||
}
|
||||
@@ -1015,7 +1075,7 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
||||
g.set(r, l + j, shown, ck.1, 0, data_fmt.get(*vi).cloned().flatten());
|
||||
}
|
||||
}
|
||||
Ok(Output { width: g.width as u32, grid: g.rows, row_kinds: g.kinds, body_offset })
|
||||
Ok(Output { width: g.width as u32, grid: g.rows, row_kinds: g.kinds, body_offset, chart_rows, chart_cols, label_col })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- styling and writing
|
||||
@@ -1205,9 +1265,105 @@ pub fn refresh(wb: &mut Workbook, sheet: usize, idx: usize, replace: bool) -> Re
|
||||
if let Some(p) = shm.pivots.get_mut(idx) {
|
||||
p.last_range = Some(area);
|
||||
}
|
||||
let sheet_name = shm.name.clone();
|
||||
rebind_charts(wb, &sheet_name, &pt.name, origin, &out);
|
||||
Ok(area)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- PivotCharts
|
||||
|
||||
/// The link a PivotChart stores to its PivotTable: `Sheet2!PivotTable1`.
|
||||
pub fn chart_link(sheet: &str, pivot: &str) -> String {
|
||||
format!("{}!{pivot}", quote_sheet(sheet))
|
||||
}
|
||||
|
||||
/// (sheet, PivotTable name) of a PivotChart link; a leading `[Book.xlsx]` is ignored.
|
||||
pub fn parse_chart_link(link: &str) -> Option<(String, String)> {
|
||||
let t = link.trim();
|
||||
let t = match t.strip_prefix('[') {
|
||||
Some(rest) => rest.split_once(']').map(|(_, r)| r).unwrap_or(rest),
|
||||
None => t,
|
||||
};
|
||||
let (sheet, name) = t.rsplit_once('!')?;
|
||||
let sheet = sheet.trim();
|
||||
let sheet = sheet.strip_prefix('\'').and_then(|x| x.strip_suffix('\'')).map(|x| x.replace("''", "'")).unwrap_or_else(|| sheet.to_string());
|
||||
let name = name.trim();
|
||||
if sheet.is_empty() || name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((sheet, name.to_string()))
|
||||
}
|
||||
|
||||
/// Whether a PivotChart link points at `pivot` on `sheet`.
|
||||
pub fn links_to(link: &str, sheet: &str, pivot: &str) -> bool {
|
||||
parse_chart_link(link).is_some_and(|(s, n)| s.eq_ignore_ascii_case(sheet) && n.eq_ignore_ascii_case(pivot))
|
||||
}
|
||||
|
||||
/// An absolute reference to `col` over grid `rows` (contiguous runs joined into a union).
|
||||
fn rows_ref(sheet: &str, origin: CellRef, col: u32, rows: &[u32]) -> Option<String> {
|
||||
let q = quote_sheet(sheet);
|
||||
let c = origin.col.checked_add(col)?;
|
||||
let letters = gridcraft_core::col_to_letters(c);
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
let mut i = 0;
|
||||
while let Some(&start) = rows.get(i) {
|
||||
let mut end = start;
|
||||
while rows.get(i + 1).is_some_and(|n| Some(*n) == end.checked_add(1)) {
|
||||
end += 1;
|
||||
i += 1;
|
||||
}
|
||||
i += 1;
|
||||
let (a, b) = (origin.row.checked_add(start)? + 1, origin.row.checked_add(end)? + 1);
|
||||
parts.push(if a == b { format!("{q}!${letters}${a}") } else { format!("{q}!${letters}${a}:${letters}${b}") });
|
||||
}
|
||||
match parts.len() {
|
||||
0 => None,
|
||||
1 => parts.pop(),
|
||||
_ => Some(format!("({})", parts.join(","))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Most category rows a PivotChart plots.
|
||||
const MAX_CHART_ROWS: usize = 10_000;
|
||||
|
||||
/// A PivotChart's series for a report written at `origin`: categories are the innermost row
|
||||
/// items, one series per item column (column items × value fields); totals are left out.
|
||||
pub fn chart_series(sheet: &str, origin: CellRef, out: &Output) -> Vec<Series> {
|
||||
let rows: Vec<u32> = out.chart_rows.iter().copied().take(MAX_CHART_ROWS).collect();
|
||||
let categories = out.label_col.and_then(|c| rows_ref(sheet, origin, c, &rows));
|
||||
out.chart_cols
|
||||
.iter()
|
||||
.filter_map(|(c, name)| {
|
||||
Some(Series {
|
||||
name: Some(format!("\"{}\"", name.replace('"', "\"\""))),
|
||||
categories: categories.clone(),
|
||||
values: rows_ref(sheet, origin, *c, &rows)?,
|
||||
bubble_sizes: None,
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
smooth: false,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Rebuilds the series of every PivotChart of a PivotTable after its report was written.
|
||||
fn rebind_charts(wb: &mut Workbook, sheet: &str, pivot: &str, origin: CellRef, out: &Output) {
|
||||
let linked = |ch: &Chart| ch.pivot.as_deref().is_some_and(|l| links_to(l, sheet, pivot));
|
||||
let hits: Vec<usize> = (0..wb.sheets.len()).filter(|i| wb.sheet(*i).is_some_and(|sh| sh.charts.iter().any(linked))).collect();
|
||||
if hits.is_empty() {
|
||||
return;
|
||||
}
|
||||
let series = chart_series(sheet, origin, out);
|
||||
for i in hits {
|
||||
let Some(sh) = wb.sheet_mut(i) else { continue };
|
||||
for ch in sh.charts.iter_mut().filter(|c| linked(c)) {
|
||||
ch.series = series.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears a pivot's last output area.
|
||||
pub fn clear_output(wb: &mut Workbook, sheet: usize, idx: usize) {
|
||||
let Some(shm) = wb.sheet_mut(sheet) else { return };
|
||||
|
||||
@@ -390,6 +390,10 @@ pub struct Chart {
|
||||
/// Source range when created from a selection (for Switch Row/Column).
|
||||
pub source: Option<String>,
|
||||
pub by_rows: bool,
|
||||
/// A PivotChart's PivotTable (`Sheet2!PivotTable1`): its series are rebuilt from the report
|
||||
/// whenever the PivotTable is refreshed or its layout changes.
|
||||
#[serde(default)]
|
||||
pub pivot: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -184,6 +184,7 @@ pub fn read_chart(root: &El) -> Option<Chart> {
|
||||
y_title: y_ax.and_then(|a| a.child("title")).and_then(rich_title),
|
||||
source: None,
|
||||
by_rows: false,
|
||||
pivot: root.path(&["pivotSource", "name"]).map(|n| n.text.trim().to_string()).filter(|n| !n.is_empty()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -465,6 +466,10 @@ pub fn write_chart(wb: &Workbook, si: usize, ch: &Chart) -> String {
|
||||
if (1..=48).contains(&ch.style) {
|
||||
let _ = write!(s, "<c:style val=\"{}\"/>", ch.style);
|
||||
}
|
||||
// A PivotChart names its PivotTable (`Sheet2!PivotTable1`).
|
||||
if let Some(link) = ch.pivot.as_deref().map(str::trim).filter(|l| !l.is_empty()) {
|
||||
let _ = write!(s, "<c:pivotSource><c:name>{}</c:name><c:fmtId val=\"0\"/></c:pivotSource>", esc(link));
|
||||
}
|
||||
s.push_str("<c:chart>");
|
||||
match &ch.title {
|
||||
Some(t) if !t.is_empty() => {
|
||||
|
||||
@@ -134,6 +134,7 @@ pub fn read_chartex(root: &El) -> Option<Chart> {
|
||||
y_title: axis_title("1"),
|
||||
source: None,
|
||||
by_rows: false,
|
||||
pivot: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -418,6 +418,7 @@ fn chartex_parts_as_excel_expects_them() {
|
||||
y_title: None,
|
||||
source: None,
|
||||
by_rows: false,
|
||||
pivot: None,
|
||||
});
|
||||
wb.sheets = vec![std::sync::Arc::new(sheet)];
|
||||
let bytes = crate::write_xlsx(&wb).unwrap();
|
||||
|
||||
@@ -299,6 +299,7 @@ fn sample() -> Workbook {
|
||||
y_title: Some("Amount".into()),
|
||||
source: None,
|
||||
by_rows: false,
|
||||
pivot: None,
|
||||
};
|
||||
for kind in [
|
||||
ChartKind::ColumnClustered,
|
||||
|
||||
@@ -33,6 +33,7 @@ fn smooth_line_setting_round_trips() {
|
||||
y_title: None,
|
||||
source: None,
|
||||
by_rows: false,
|
||||
pivot: None,
|
||||
});
|
||||
let mut smooth_series = base_series.clone();
|
||||
smooth_series.smooth = true;
|
||||
@@ -50,6 +51,7 @@ fn smooth_line_setting_round_trips() {
|
||||
y_title: None,
|
||||
source: None,
|
||||
by_rows: false,
|
||||
pivot: None,
|
||||
});
|
||||
wb.sheets = vec![Arc::new(s)];
|
||||
let bytes = write_xlsx(&wb).unwrap();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Chart parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from a source audit of the chart model, renderer and XLSX chart parts) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (PivotCharts) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
|
||||
Excel's chart types and chart elements against GridCraft. Part of
|
||||
[target-app-parity.md](target-app-parity.md). **Breadth ~50%, ready ~30%, 40–60 h** (estimated).
|
||||
@@ -53,7 +53,7 @@ open PRs #158, #161, and issue #190.
|
||||
| Recommended Charts | yes | partial (Insert ▸ Recommended) |
|
||||
| Switch Row/Column, Select Data | yes | yes (Switch on charts read from files landed in #116) |
|
||||
| Sparklines (line, column, win/loss, markers, axis) | yes | line, column, win/loss |
|
||||
| PivotCharts | yes | **missing** |
|
||||
| PivotCharts | yes | Insert ▸ PivotChart (`insert.pivotChart`): bound to a PivotTable's report, rebuilt on refresh and layout changes, `c:pivotSource` round trip; no field buttons, innermost category level only |
|
||||
| Animation on data change | yes (Windows) | no |
|
||||
|
||||
## XLSX round-trip
|
||||
@@ -72,11 +72,12 @@ back.
|
||||
4. Trendlines and error bars (maths shared with LINEST/LOGEST/GROWTH): 6–10 h.
|
||||
5. Per-element Format pane parity, smoothed lines, exploded pie, pie/bar of pie, 100% variants: 8–12 h.
|
||||
6. 3-D column/bar/pie/area, surface and contour, Pareto: 8–12 h.
|
||||
7. Chart sheets, PivotCharts, templates: 6–10 h. Filled map: owner decision on boundary data.
|
||||
7. Chart sheets, templates, PivotChart field buttons and multi-level categories: 6–10 h. Filled map: owner decision on boundary data.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | PivotCharts (`insert.pivotChart`) |
|
||||
| 2026-10-10 | minor | Smooth lines (#62) and Switch Row/Column on file charts (#116) landed; chart resize bug (#190) |
|
||||
| 2026-10-10 | major | Created from a source audit: 25 kinds (25 of ~60 subtypes), element gaps, round-trip loss |
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
# Gaps
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the full re-measure against Excel for Mac 16.113.4 and the issue tracker) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (§8: PivotCharts landed) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone
|
||||
using GridCraft for real work first). This is the work list: pick from the top, check the open
|
||||
@@ -70,9 +70,9 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|
||||
routine in business.
|
||||
- **Estimate:** 28–42 h (`.xls` read 20–30, encryption 8–12). [file-format-parity.md](file-format-parity.md)
|
||||
|
||||
### 8. PivotTables: no slicers, calculated fields, PivotCharts; unverified in Excel (F)
|
||||
### 8. PivotTables: no slicers, calculated fields; unverified in Excel (F)
|
||||
- **Evidence:** `parity-checklist.md` missing `insert.slicer`, `insert.timeline`,
|
||||
`insert.pivotChart`, `pivot.calculatedField`, `table.insertSlicer`; GETPIVOTDATA missing.
|
||||
`pivot.calculatedField`, `table.insertSlicer`; GETPIVOTDATA missing. PivotCharts done (no field buttons).
|
||||
- **Impact:** pivots are the analyst's main tool.
|
||||
- **Estimate:** 20–30 h for beta (25–40 h full).
|
||||
|
||||
@@ -149,6 +149,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | §8: PivotCharts added |
|
||||
| 2026-10-10 | minor | Updated for the 10-10 merges (#17, #31, #38, #62, #82, #104, #116) and the new chart resize bug (#190) |
|
||||
| 2026-10-10 | minor | Noted that no gap blocks the alpha gate |
|
||||
| 2026-10-10 | major | Created: 24 ranked gaps with evidence, from the full re-measure and the 60 open issues |
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
|
||||
Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md).
|
||||
|
||||
**Overall: 258 / 290 commands implemented (89.0%).**
|
||||
**Overall: 259 / 290 commands implemented (89.3%).**
|
||||
|
||||
| Tab | Implemented | Total | Parity |
|
||||
|---|---|---|---|
|
||||
| File | 10 | 11 | 91% |
|
||||
| Home | 94 | 94 | 100% |
|
||||
| Insert | 15 | 25 | 60% |
|
||||
| Insert | 16 | 25 | 64% |
|
||||
| Draw | 4 | 4 | 100% |
|
||||
| Page Layout | 23 | 23 | 100% |
|
||||
| Formulas | 23 | 23 | 100% |
|
||||
@@ -142,7 +142,7 @@ Implemented:
|
||||
- `edit.goToSpecial` Go To Special (Editing)
|
||||
- `home.addIns` Add-ins (Add-ins)
|
||||
|
||||
## Insert (15 / 25, 60%)
|
||||
## Insert (16 / 25, 64%)
|
||||
|
||||
Implemented:
|
||||
|
||||
@@ -154,6 +154,7 @@ Implemented:
|
||||
- `insert.icons` Icons (Illustrations)
|
||||
- `insert.recommendedCharts` Recommended Charts (Charts)
|
||||
- `insert.chart` Charts (Charts)
|
||||
- `insert.pivotChart` PivotChart (Charts)
|
||||
- `insert.sparkline` Sparklines (Sparklines)
|
||||
- `insert.link` Link (Links)
|
||||
- `review.newComment` Comment (Comments)
|
||||
@@ -166,7 +167,6 @@ Missing:
|
||||
|
||||
- `insert.smartArt` SmartArt (Illustrations)
|
||||
- `insert.screenshot` Screenshot (Illustrations)
|
||||
- `insert.pivotChart` PivotChart (Charts)
|
||||
- `insert.map` Maps (Charts)
|
||||
- `insert.slicer` Slicer (Filters)
|
||||
- `insert.timeline` Timeline (Filters)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Target-app parity: GridCraft vs Microsoft Excel
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first full assessment; re-measure against Excel for Mac 16.113.4, replacing the estimates that lived in ROADMAP.md) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (PivotTables: PivotCharts) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it,
|
||||
[`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail:
|
||||
@@ -84,7 +84,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2
|
||||
| Editing, navigation, clipboard, fill | 12 | ~90% | ~70% | 15–25 | Point mode, F4, AutoComplete, Paste Special, AutoFill, Flash Fill, Find/Replace, Go To Special. Bugs: rectangular drag-select (#28), monthly fill (#132), cut across sheets (#128), Insert Copied Cells missing (#21), key tips only on part of the Home tab (#43; #33, #49). [ui-parity.md](ui-parity.md) |
|
||||
| Cell formatting, styles, conditional formatting | 10 | ~95% | ~80% | 8–14 | Full number-format language, borders, 47 cell styles, 60 table styles, all CF rule kinds including data bars, colour scales and icon sets. Gaps: font weights beyond regular/bold (#168), system fonts list (#4), themes |
|
||||
| Sort, filter, tables, validation, outline | 8 | ~82% | ~70% | 10–16 | Multi-level sort, AutoFilter, Advanced Filter, tables with totals, validation, outline, subtotals, consolidate. Bugs: subtotal header (#131), validation edits (#129), table style panic caught (#130); no slicers on tables |
|
||||
| PivotTables | 7 | ~65% | ~45% | 25–40 | Insert/Recommended, field list, three layouts, date grouping, show-values-as, refresh, XLSX round-trip. Missing: slicers, timelines, calculated fields/items, PivotCharts, value/label filters, number grouping, GETPIVOTDATA, data-model pivots. Round-trip never checked in Excel |
|
||||
| PivotTables | 7 | ~65% | ~45% | 25–40 | Insert/Recommended, field list, three layouts, date grouping, show-values-as, refresh, PivotCharts, XLSX round-trip. Missing: slicers, timelines, calculated fields/items, value/label filters, number grouping, GETPIVOTDATA, data-model pivots. Round-trip never checked in Excel |
|
||||
| Charts and sparklines | 8 | ~50% | ~30% | 40–60 | 25 chart kinds, all 2-D. The chart model holds title, legend position, a data-labels flag, gridlines flag and axis titles; no axis scaling, trendlines, error bars, per-point formatting, chart sheets, 3-D, surface, map, Pareto. Several kinds don't survive XLSX round trip (open PRs #158, #161). [chart-parity.md](chart-parity.md) |
|
||||
| Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, Page Break Preview, headers/footers, PDF export and printing. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) |
|
||||
| What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables. Missing: Solver, Analysis ToolPak, Forecast Sheet, Analyze Data |
|
||||
@@ -332,6 +332,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | PivotTables: PivotCharts (`insert.pivotChart`) |
|
||||
| 2026-10-10 | minor | Readiness-by-audience table: hours to ~95% for each of the three numbers (full 650–1,050, mainstream 200–310, essentials 60–100). Full number confirmed as the additive weighted sum over the written dimension weights (no change) |
|
||||
| 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%) with written weights and discounts, and user-evidence counts; re-checked the full number after the 10-10 merges (charts 38→30, localization 5→10; still 51.6, ~50%) |
|
||||
| 2026-10-10 | minor | Stage checked against the new alpha gate (six core workflows, all pass): stays alpha |
|
||||
|
||||
Reference in New Issue
Block a user