Merge pull request #718 from neilcawse:fix/pivot-values-on-rows

This commit is contained in:
Brandon Thomas 2026-10-12 23:49:00 -04:00
commit 98c85c6de4
8 changed files with 602 additions and 90 deletions

View File

@ -87,7 +87,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Field",
[],
None,
"{pivot?, field, from: rows|columns|values|filters, to: rows|columns|values|filters, position?: index, replace?}",
"{pivot?, field (\"Σ Values\" moves the value fields between rows and columns; in columns it stays after the last field), from: rows|columns|values|filters, to: rows|columns|values|filters, position?: index, replace?}",
has_doc,
move_field
),
@ -333,6 +333,71 @@ fn in_axes(pt: &PivotTable, field: &str) -> bool {
pt.rows.iter().chain(pt.columns.iter()).any(|f| f.source_col.eq_ignore_ascii_case(field))
}
/// Name of the Σ Values pseudo-field (where the value fields sit when there are two or more).
const VALUES_FIELD: &str = "Σ Values";
/// Whether `field` names Σ Values: `Σ Values`, or `Values` unless a source field has that name.
fn is_values_field(src: &pv::Source, field: &str) -> bool {
let f = field.trim();
f == VALUES_FIELD || (f.eq_ignore_ascii_case("values") && src.col(f).is_none())
}
/// Keeps Σ Values in place after a field left (`inserted` false) or arrived at index `i` of
/// Rows (`rows`) or Columns.
fn shift_values(pt: &mut PivotTable, rows: bool, i: usize, inserted: bool) {
if pt.values_on_rows != rows {
return;
}
let Some(p) = pt.values_position else { return };
// Σ Values stays just ahead of the field it preceded.
let p = match inserted {
true if i <= p => p + 1,
false if i < p => p - 1,
_ => p,
};
let n = if rows { pt.rows.len() } else { pt.columns.len() };
pt.values_position = (p < n).then_some(p);
}
/// Takes `field` out of Rows and Columns (Σ Values keeps its place); returns its first entry.
fn take_from_axes(pt: &mut PivotTable, field: &str) -> Option<PivotField> {
let mut first = None;
for rows in [true, false] {
loop {
let list = if rows { &mut pt.rows } else { &mut pt.columns };
let Some(i) = list.iter().position(|f| f.source_col.eq_ignore_ascii_case(field)) else { break };
let f = list.remove(i);
shift_values(pt, rows, i, false);
first.get_or_insert(f);
}
}
first
}
/// Inserts `f` into Rows (`rows`) or Columns at `position` (default: the end).
fn insert_axis_field(pt: &mut PivotTable, rows: bool, f: PivotField, position: Option<usize>) {
let list = if rows { &mut pt.rows } else { &mut pt.columns };
let at = position.unwrap_or(list.len()).min(list.len());
list.insert(at, f);
shift_values(pt, rows, at, true);
}
/// Moves Σ Values between Rows and Columns, or within Rows, to `position` (default: the end).
/// In Columns it always comes after the last column field: the column layout doesn't place it
/// anywhere else yet.
fn move_values_field(cmd: &str, pt: &mut PivotTable, from: &str, to: &str, position: Option<usize>) -> Result<Json> {
let current = if pt.values_on_rows { "rows" } else { "columns" };
if from != current {
return Err(bad(cmd, format!("`{VALUES_FIELD}` isn't in the {from} area")));
}
if !matches!(to, "rows" | "columns") {
return Err(other("The field you are moving cannot be placed in that PivotTable area."));
}
pt.values_on_rows = to == "rows";
pt.values_position = position.filter(|p| pt.values_on_rows && *p < pt.rows.len());
Ok(json!({"field": VALUES_FIELD, "to": to}))
}
/// The canonical source field name for `field`.
fn canonical(cmd: &str, src: &pv::Source, field: &str) -> Result<String> {
src.col(field).and_then(|c| src.headers.get(c).cloned()).ok_or_else(|| bad(cmd, format!("no field `{field}` in the source data")))
@ -384,21 +449,11 @@ fn add_to_area(
if pt.rows.len() + pt.columns.len() >= pv::MAX_AXIS_FIELDS && !in_axes(pt, &field) {
return Err(other(format!("A PivotTable can have at most {} row and column fields.", pv::MAX_AXIS_FIELDS)));
}
let existing = pt
.rows
.iter()
.chain(pt.columns.iter())
.find(|f| f.source_col.eq_ignore_ascii_case(&field))
.cloned()
.unwrap_or_else(|| PivotField { source_col: field.clone(), ..Default::default() });
pt.rows.retain(|f| !f.source_col.eq_ignore_ascii_case(&field));
pt.columns.retain(|f| !f.source_col.eq_ignore_ascii_case(&field));
let existing = take_from_axes(pt, &field).unwrap_or_else(|| PivotField { source_col: field.clone(), ..Default::default() });
// A report filter that selects nothing in particular goes away; a selection stays
// as the field's hidden items.
pt.filters.retain(|f| !(f.source_col.eq_ignore_ascii_case(&field) && f.selected.is_none()));
let list = if area == "rows" { &mut pt.rows } else { &mut pt.columns };
let at = position.unwrap_or(list.len()).min(list.len());
list.insert(at, existing);
insert_axis_field(pt, area == "rows", existing, position);
}
"values" => {
if pt.values.len() >= pv::MAX_VALUE_FIELDS {
@ -421,8 +476,7 @@ fn add_to_area(
pt.values.insert(at, v);
}
_ => {
pt.rows.retain(|f| !f.source_col.eq_ignore_ascii_case(&field));
pt.columns.retain(|f| !f.source_col.eq_ignore_ascii_case(&field));
take_from_axes(pt, &field);
let existing = pt.filters.iter().position(|f| f.source_col.eq_ignore_ascii_case(&field));
let entry = match existing {
Some(i) => pt.filters.remove(i),
@ -439,12 +493,14 @@ fn add_to_area(
fn remove_from_area(cmd: &str, pt: &mut PivotTable, field: &str, area: &str) -> Result<String> {
match area {
"rows" | "columns" => {
let list = if area == "rows" { &mut pt.rows } else { &mut pt.columns };
let rows = area == "rows";
let list = if rows { &mut pt.rows } else { &mut pt.columns };
let i = list
.iter()
.position(|f| f.source_col.eq_ignore_ascii_case(field))
.ok_or_else(|| bad(cmd, format!("`{field}` isn't in the {area} area")))?;
let f = list.remove(i);
shift_values(pt, rows, i, false);
pt.filters.retain(|x| !x.source_col.eq_ignore_ascii_case(&f.source_col));
Ok(f.source_col)
}
@ -699,6 +755,8 @@ fn pivot_json(wb: &Workbook, si: usize, pt: &PivotTable) -> Json {
"style": pt.style,
"showHeaders": pt.show_headers,
"calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::<Vec<_>>(),
"valuesOnRows": pt.values_on_rows,
"valuesPosition": pt.values_position,
})
}
@ -890,18 +948,22 @@ fn move_field(s: &mut Session, p: &Json) -> Result<Json> {
let to = area_param(C, str_param(p, "to"))?;
let pos = position_param(p);
update(s, p, |pt, src, _| {
if is_values_field(src, &field) {
return move_values_field(C, pt, from, to, pos);
}
if from == to {
// Reorder within the area.
match from {
"rows" | "columns" => {
let list = if from == "rows" { &mut pt.rows } else { &mut pt.columns };
let rows = from == "rows";
let list = if rows { &mut pt.rows } else { &mut pt.columns };
let i = list
.iter()
.position(|f| f.source_col.eq_ignore_ascii_case(&field))
.ok_or_else(|| bad(C, format!("`{field}` isn't in the {from} area")))?;
let f = list.remove(i);
let at = pos.unwrap_or(list.len()).min(list.len());
list.insert(at, f);
shift_values(pt, rows, i, false);
insert_axis_field(pt, rows, f, pos);
}
"values" => {
let i = pt
@ -1270,8 +1332,20 @@ fn change_source(s: &mut Session, p: &Json) -> Result<Json> {
}
ok
};
pt.rows.retain(|f| keep(&f.source_col, &mut dropped));
pt.columns.retain(|f| keep(&f.source_col, &mut dropped));
// Axis fields leave one by one so Σ Values keeps its place among the rest.
for rows in [true, false] {
let mut i = 0;
loop {
let list = if rows { &mut pt.rows } else { &mut pt.columns };
let Some(f) = list.get(i) else { break };
if keep(&f.source_col, &mut dropped) {
i += 1;
} else {
list.remove(i);
shift_values(pt, rows, i, false);
}
}
}
pt.values.retain(|f| keep(&f.source_col, &mut dropped));
pt.filters.retain(|f| keep(&f.source_col, &mut dropped));
Ok(json!({"source": source, "dropped": dropped}))
@ -1722,6 +1796,167 @@ mod tests {
assert_eq!(text(&s, "A5"), "Apples");
}
#[test]
fn values_on_rows_layouts() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales", "Units"]})).unwrap();
assert_eq!(text(&s, "C3"), "Sum of Units");
// Σ Values after Region: each region holds one line per value field.
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows"})).unwrap();
let p = pt(&s);
assert!(p.values_on_rows);
assert_eq!(p.values_position, None);
let r = p.last_range.unwrap();
assert_eq!(r.width(), 2);
// header + 3 × (region + 2 values) + 2 grand totals
assert_eq!(r.height(), 1 + 9 + 2);
assert_eq!(text(&s, "A4"), "East");
assert_eq!(val(&s, "B4"), Value::Empty);
assert_eq!(text(&s, "A5"), "Sum of Sales");
assert_eq!(val(&s, "B5"), Value::Number(415.0));
assert_eq!(text(&s, "A6"), "Sum of Units");
assert_eq!(val(&s, "B6"), Value::Number(33.0));
assert_eq!(text(&s, "A13"), "Total Sum of Sales");
assert_eq!(val(&s, "B13"), Value::Number(TOTAL));
assert_eq!(text(&s, "A14"), "Total Sum of Units");
assert_eq!(val(&s, "B14"), Value::Number(72.0));
// Tabular, no column field: "Total" heads the data column, as Excel saves it.
s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap();
assert_eq!(text(&s, "A3"), "Region");
assert_eq!(text(&s, "B3"), "Values");
assert_eq!(text(&s, "C3"), "Total");
s.execute("pivot.layout", json!({"layout": "compact"})).unwrap();
// Σ Values before Region: the value fields are the outer items.
s.execute("pivot.moveField", json!({"field": "Values", "from": "rows", "to": "rows", "position": 0})).unwrap();
assert_eq!(pt(&s).values_position, Some(0));
assert_eq!(text(&s, "A4"), "Sum of Sales");
assert_eq!(val(&s, "B4"), Value::Empty);
assert_eq!(text(&s, "A5"), "East");
assert_eq!(val(&s, "B5"), Value::Number(415.0));
assert_eq!(text(&s, "A8"), "Sum of Units");
assert_eq!(text(&s, "A9"), "East");
assert_eq!(val(&s, "B9"), Value::Number(33.0));
assert_eq!(text(&s, "A12"), "Total Sum of Sales");
assert_eq!(val(&s, "B13"), Value::Number(72.0));
// Adding a row field in front keeps Σ Values ahead of Region.
s.execute("pivot.addField", json!({"field": "Product", "area": "rows", "position": 0})).unwrap();
assert_eq!(pt(&s).values_position, Some(1));
s.execute("pivot.removeField", json!({"field": "Product", "area": "rows"})).unwrap();
assert_eq!(pt(&s).values_position, Some(0));
// No row field: one line per value field and no grand total.
s.execute("pivot.removeField", json!({"field": "Region", "area": "rows"})).unwrap();
let r = pt(&s).last_range.unwrap();
assert_eq!(r.height(), 1 + 2);
assert_eq!(text(&s, "A4"), "Sum of Sales");
assert_eq!(val(&s, "B4"), Value::Number(TOTAL));
assert_eq!(text(&s, "A5"), "Sum of Units");
assert_eq!(val(&s, "B5"), Value::Number(72.0));
// Back to Columns.
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).unwrap();
assert!(!pt(&s).values_on_rows);
assert_eq!(text(&s, "A3"), "Sum of Sales");
assert_eq!(text(&s, "B3"), "Sum of Units");
assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "filters"})).is_err());
assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).is_err());
}
#[test]
fn collapsed_item_puts_values_in_the_values_column() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales", "Units"]})).unwrap();
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows"})).unwrap();
s.execute("pivot.collapse", json!({"field": "Region", "items": ["East"]})).unwrap();
for layout in ["tabular", "outline"] {
s.execute("pivot.layout", json!({"layout": layout})).unwrap();
assert_eq!(text(&s, "C3"), "Values", "{layout}");
let east = (4..20).find(|r| text(&s, &format!("A{r}")) == "East").unwrap();
let first = if layout == "tabular" { east } else { east + 1 };
assert_eq!(text(&s, &format!("B{first}")), "", "{layout}");
assert_eq!(text(&s, &format!("C{first}")), "Sum of Sales", "{layout}");
assert_eq!(val(&s, &format!("D{first}")), Value::Number(415.0), "{layout}");
assert_eq!(text(&s, &format!("C{}", first + 1)), "Sum of Units", "{layout}");
}
}
#[test]
fn values_stay_last_in_columns() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Product"], "columns": ["Region"], "values": ["Sales", "Units"]})).unwrap();
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "columns", "position": 0})).unwrap();
let p = pt(&s);
assert!(!p.values_on_rows);
assert_eq!(p.values_position, None);
// Σ Values to Rows and back with a position: still last in Columns.
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows"})).unwrap();
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns", "position": 0})).unwrap();
assert_eq!(pt(&s).values_position, None);
assert_eq!(text(&s, "B5"), "Sum of Sales");
assert_eq!(text(&s, "C5"), "Sum of Units");
}
#[test]
fn change_source_keeps_values_ahead_of_its_field() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales", "Units"]})).unwrap();
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows", "position": 1})).unwrap();
// The new source has no Region: Σ Values stays ahead of Product.
s.execute("pivot.changeSource", json!({"source": "Sheet1!B1:E13"})).unwrap();
let p = pt(&s);
assert_eq!(p.rows.len(), 1);
assert_eq!(p.values_position, Some(0));
assert_eq!(text(&s, "A4"), "Sum of Sales");
assert_eq!(text(&s, "A5"), "Apples");
}
#[test]
fn values_on_rows_between_row_fields() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales", "Units"]})).unwrap();
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows", "position": 1})).unwrap();
// Compact: East, then per value field its products, then per-value subtotals of East.
assert_eq!(text(&s, "A4"), "East");
assert_eq!(text(&s, "A5"), "Sum of Sales");
assert_eq!(text(&s, "A6"), "Apples");
assert_eq!(val(&s, "B6"), Value::Number(160.0));
assert_eq!(text(&s, "A9"), "Sum of Units");
assert_eq!(text(&s, "A10"), "Apples");
assert_eq!(val(&s, "B10"), Value::Number(16.0));
assert_eq!(text(&s, "A13"), "East Sum of Sales");
assert_eq!(val(&s, "B13"), Value::Number(415.0));
assert_eq!(text(&s, "A14"), "East Sum of Units");
assert_eq!(val(&s, "B14"), Value::Number(33.0));
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A5").unwrap())).align.indent, 1);
assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A6").unwrap())).align.indent, 2);
// Outline: one column per level, "Values" heads the Σ Values column.
s.execute("pivot.layout", json!({"layout": "outline"})).unwrap();
assert_eq!(pt(&s).last_range.unwrap().width(), 4);
assert_eq!(text(&s, "A3"), "Region");
assert_eq!(text(&s, "B3"), "Values");
assert_eq!(text(&s, "C3"), "Product");
assert_eq!(text(&s, "B5"), "Sum of Sales");
assert_eq!(text(&s, "C6"), "Apples");
assert_eq!(val(&s, "D6"), Value::Number(160.0));
// Tabular: labels start on their parent's row; totals per value field below each group.
s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap();
assert_eq!(text(&s, "A4"), "East");
assert_eq!(text(&s, "B4"), "Sum of Sales");
assert_eq!(text(&s, "C4"), "Apples");
assert_eq!(val(&s, "D4"), Value::Number(160.0));
assert_eq!(text(&s, "B7"), "Sum of Units");
assert_eq!(text(&s, "C7"), "Apples");
assert_eq!(val(&s, "D7"), Value::Number(16.0));
assert_eq!(text(&s, "A10"), "East Sum of Sales");
assert_eq!(val(&s, "D10"), Value::Number(415.0));
assert_eq!(text(&s, "A11"), "East Sum of Units");
let r = pt(&s).last_range.unwrap();
let last = r.end.row + 1;
assert_eq!(text(&s, &format!("A{}", last - 1)), "Total Sum of Sales");
assert_eq!(text(&s, &format!("A{last}")), "Total Sum of Units");
assert_eq!(val(&s, &format!("D{last}")), Value::Number(72.0));
}
#[test]
fn refresh_after_source_edits() {
let mut s = sales();

View File

@ -560,6 +560,8 @@ struct Line {
values: bool,
/// (column, value, indent, number format)
labels: Vec<(usize, Value, u8, Option<String>)>,
/// The value field of the line when Σ Values sits in Rows (`None`: one per data column).
vi: Option<usize>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
@ -579,6 +581,10 @@ struct Ctx<'a> {
rows: Vec<&'a PivotField>,
row_items: Vec<FieldItems>,
row_fmts: Vec<String>,
/// Depth of Σ Values among the row fields when it sits in Rows (two or more value fields).
vpos: Option<usize>,
/// Value field captions.
val_names: Vec<String>,
}
impl Ctx<'_> {
@ -588,26 +594,105 @@ impl Ctx<'_> {
(it.value.clone(), fmt, it.label.clone())
}
fn flatten(&self, nodes: &[Node], depth: usize, prefix: &mut Vec<u32>, out: &mut Vec<Line>) {
/// (column, indent) of a row level (Σ Values counts as a level) in Compact/Outline form.
fn level_cell(&self, level: usize) -> (usize, u8) {
if self.pt.layout == PivotLayout::Outline { (level, 0) } else { (0, level.min(250) as u8) }
}
/// Σ Values comes right here, at row field depth `depth` (`vi`: the value field already chosen above).
fn values_here(&self, depth: usize, vi: Option<usize>) -> bool {
vi.is_none() && self.vpos == Some(depth)
}
/// Per-value subtotal lines of an item with Σ Values below it ("East Sum of Sales").
fn value_subtotals(&self, col: usize, indent: u8, label: &str, prefix: &[u32], out: &mut Vec<Line>) {
for (vi, name) in self.val_names.iter().enumerate() {
out.push(Line {
kind: LineKind::Subtotal,
leaf: false,
path: prefix.to_vec(),
values: true,
labels: vec![(col, Value::text(format!("{label} {name}")), indent, None)],
vi: Some(vi),
});
}
}
/// The Σ Values level at row field depth `depth`: one item per value field, each followed by
/// the row fields below it (`nodes`) or, innermost, holding that value.
fn flatten_values(&self, nodes: &[Node], depth: usize, prefix: &mut Vec<u32>, out: &mut Vec<Line>) {
let (col, indent) = self.level_cell(depth);
for (vi, name) in self.val_names.iter().enumerate() {
let labels = vec![(col, Value::text(name.as_str()), indent, None)];
let leaf = nodes.is_empty();
out.push(Line { kind: LineKind::Item(!leaf), leaf, path: prefix.clone(), values: leaf, labels, vi: Some(vi) });
if !leaf {
self.flatten(nodes, depth, Some(vi), prefix, out);
}
}
}
fn flatten(&self, nodes: &[Node], depth: usize, vi: Option<usize>, prefix: &mut Vec<u32>, out: &mut Vec<Line>) {
if self.values_here(depth, vi) {
self.flatten_values(nodes, depth, prefix, out);
return;
}
let n = self.rows.len();
let outline = self.pt.layout == PivotLayout::Outline;
// Σ Values still to come below this level: totals need one line per value field.
let values_below = vi.is_none() && self.vpos.is_some();
let (col, indent) = self.level_cell(depth + usize::from(vi.is_some() && self.vpos.is_some()));
for node in nodes {
prefix.push(node.item);
let (v, fmt, label) = self.label_cell(depth, node.item);
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() {
let leaf = depth + 1 >= n || collapsed || node.children.is_empty();
if leaf && !values_below {
out.push(Line {
kind: LineKind::Item(depth + 1 < n),
leaf: true,
path: prefix.clone(),
values: true,
labels: vec![(col, v, indent, fmt)],
vi,
});
} else if leaf {
out.push(Line {
kind: LineKind::Item(true),
leaf: false,
path: prefix.clone(),
values: false,
labels: vec![(col, v, indent, fmt)],
vi,
});
// Outline gives each level its own column, so the captions go in the Values
// column even when the levels between are collapsed away.
let level = if self.pt.layout == PivotLayout::Outline { self.vpos.unwrap_or(depth + 1) } else { depth + 1 };
self.flatten_values(&[], level, prefix, out);
} else if values_below {
// Subtotals per value field, below the group (one line can't hold them all).
out.push(Line {
kind: LineKind::Item(true),
leaf: false,
path: prefix.clone(),
values: false,
labels: vec![(col, v, indent, fmt)],
vi,
});
self.flatten(&node.children, depth + 1, None, prefix, out);
if self.pt.subtotals {
self.value_subtotals(col, indent, &label, prefix, out);
}
} else {
let top = self.pt.subtotals && self.pt.subtotals_top;
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);
out.push(Line {
kind: LineKind::Item(true),
leaf: false,
path: prefix.clone(),
values: top,
labels: vec![(col, v, indent, fmt)],
vi,
});
self.flatten(&node.children, depth + 1, vi, prefix, out);
if self.pt.subtotals && !self.pt.subtotals_top {
out.push(Line {
kind: LineKind::Subtotal,
@ -615,6 +700,7 @@ impl Ctx<'_> {
path: prefix.clone(),
values: true,
labels: vec![(col, Value::text(format!("{label} Total")), indent, None)],
vi,
});
}
}
@ -622,7 +708,8 @@ impl Ctx<'_> {
}
}
fn flatten_tabular(
/// Tabular form of [`Self::flatten_values`]: the first line also carries the parents' labels.
fn flatten_tabular_values(
&self,
nodes: &[Node],
depth: usize,
@ -630,24 +717,57 @@ impl Ctx<'_> {
pending: &[(usize, Value, u8, Option<String>)],
out: &mut Vec<Line>,
) {
for (vi, name) in self.val_names.iter().enumerate() {
let mut labels: Vec<(usize, Value, u8, Option<String>)> = if vi == 0 { pending.to_vec() } else { vec![] };
labels.push((depth, Value::text(name.as_str()), 0, None));
if nodes.is_empty() {
out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels, vi: Some(vi) });
} else {
self.flatten_tabular(nodes, depth, Some(vi), prefix, &labels, out);
}
}
}
fn flatten_tabular(
&self,
nodes: &[Node],
depth: usize,
vi: Option<usize>,
prefix: &mut Vec<u32>,
pending: &[(usize, Value, u8, Option<String>)],
out: &mut Vec<Line>,
) {
if self.values_here(depth, vi) {
self.flatten_tabular_values(nodes, depth, prefix, pending, out);
return;
}
let n = self.rows.len();
let values_below = vi.is_none() && self.vpos.is_some();
let col = depth + usize::from(vi.is_some() && self.vpos.is_some());
for (i, node) in nodes.iter().enumerate() {
prefix.push(node.item);
let (v, fmt, label) = self.label_cell(depth, node.item);
let mut labels: Vec<(usize, Value, u8, Option<String>)> = if i == 0 { pending.to_vec() } else { vec![] };
labels.push((depth, v, 0, fmt));
labels.push((col, 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), leaf: true, path: prefix.clone(), values: true, labels });
if values_below {
self.flatten_tabular_values(&[], self.vpos.unwrap_or(depth + 1), prefix, &labels, out);
} else {
out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels, vi });
}
} else {
self.flatten_tabular(&node.children, depth + 1, prefix, &labels, out);
if self.pt.subtotals {
self.flatten_tabular(&node.children, depth + 1, vi, prefix, &labels, out);
if self.pt.subtotals && values_below {
self.value_subtotals(col, 0, &label, prefix, out);
} else 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)],
labels: vec![(col, Value::text(format!("{label} Total")), 0, None)],
vi,
});
}
}
@ -751,6 +871,14 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
let k = vals.len();
let empty = rows.is_empty() && cols.is_empty() && vals.is_empty();
// Σ Values in Rows (it exists with two or more value fields): its depth among the row fields
// found in the source.
let vpos: Option<usize> = (pt.values_on_rows && k > 1).then(|| {
let p = pt.values_position.unwrap_or(pt.rows.len());
pt.rows.iter().take(p).filter(|f| src.col(&f.source_col).is_some()).count()
});
let on_rows = vpos.is_some();
let row_levels = rows.len() + usize::from(on_rows);
// ---- aggregate
let mut row_items: Vec<FieldItems> = rows.iter().map(|_| FieldItems::default()).collect();
@ -827,24 +955,32 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
collect_siblings(&row_tree, &mut Vec::new(), &mut siblings);
let level0: Vec<u32> = row_tree.iter().map(|n| n.item).collect();
let ctx = Ctx { pt, rows: row_fields.clone(), row_items, row_fmts: row_fmts.clone() };
let val_names: Vec<String> = vals.iter().map(|(_, v)| v.name.clone()).collect();
let ctx = Ctx { pt, rows: row_fields.clone(), row_items, row_fmts: row_fmts.clone(), vpos, val_names: val_names.clone() };
let mut lines: Vec<Line> = Vec::new();
if rows.is_empty() {
lines.push(Line { kind: LineKind::Item(false), leaf: true, path: vec![], values: true, labels: vec![] });
if row_levels == 0 {
lines.push(Line { kind: LineKind::Item(false), leaf: true, path: vec![], values: true, labels: vec![], vi: None });
} else {
if pt.layout == PivotLayout::Tabular {
ctx.flatten_tabular(&row_tree, 0, &mut Vec::new(), &[], &mut lines);
ctx.flatten_tabular(&row_tree, 0, None, &mut Vec::new(), &[], &mut lines);
} else {
ctx.flatten(&row_tree, 0, &mut Vec::new(), &mut lines);
ctx.flatten(&row_tree, 0, None, &mut Vec::new(), &mut lines);
}
if pt.grand_totals_cols {
lines.push(Line {
// With only Σ Values in Rows its items are the totals already.
if pt.grand_totals_cols && !rows.is_empty() {
let grand = |text: String, vi: Option<usize>| Line {
kind: LineKind::Grand,
leaf: false,
path: vec![],
values: true,
labels: vec![(0, Value::text("Grand Total"), 0, None)],
});
labels: vec![(0, Value::text(text), 0, None)],
vi,
};
if on_rows {
lines.extend(val_names.iter().enumerate().map(|(vi, name)| grand(format!("Total {name}"), Some(vi))));
} else {
lines.push(grand("Grand Total".into(), None));
}
}
}
let mut specs: Vec<ColSpec> = Vec::new();
@ -858,7 +994,10 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
}
let mut data_cols: Vec<(usize, Option<usize>)> = Vec::new();
for (si, _) in specs.iter().enumerate() {
if k == 0 {
if on_rows {
// Each line names its value field; one data column per column item.
data_cols.push((si, None));
} else if k == 0 {
if !cols.is_empty() {
data_cols.push((si, None));
}
@ -873,12 +1012,12 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
}
let compact = pt.layout == PivotLayout::Compact;
let l = if rows.is_empty() && cols.is_empty() {
let l = if row_levels == 0 && cols.is_empty() {
0
} else if compact {
1
} else {
rows.len().max(1)
row_levels.max(1)
};
let mut width = l + data_cols.len();
if !shown_filters.is_empty() {
@ -924,14 +1063,19 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
let val_name = |vi: usize| vals.get(vi).map(|(_, v)| v.name.clone()).unwrap_or_default();
let headers_on = pt.show_headers;
let row_header = |g: &mut Grid, r: usize| {
if rows.is_empty() || !headers_on {
if row_levels == 0 || !headers_on {
return;
}
if compact {
g.set(r, 0, Value::text("Row Labels"), CellKind::Header, 0, None);
} else {
for (i, f) in row_fields.iter().enumerate() {
g.set(r, i, Value::text(f.source_col.as_str()), CellKind::Header, 0, None);
// One column per row field, Σ Values ("Values") at its place.
let mut names: Vec<&str> = row_fields.iter().map(|f| f.source_col.as_str()).collect();
if let Some(p) = vpos {
names.insert(p.min(names.len()), "Values");
}
for (i, name) in names.into_iter().enumerate() {
g.set(r, i, Value::text(name), CellKind::Header, 0, None);
}
}
};
@ -941,8 +1085,11 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
let r = g.push_row(RowKind::Header)?;
row_header(&mut g, r);
for (j, (_, vi)) in data_cols.iter().enumerate() {
if let Some(vi) = vi {
g.set(r, l + j, Value::text(val_name(*vi)), CellKind::Header, 0, None);
match vi {
Some(vi) => g.set(r, l + j, Value::text(val_name(*vi)), CellKind::Header, 0, None),
// Values on rows, no column field: the one data column is the grand total.
None if on_rows => g.set(r, l + j, Value::text("Total"), CellKind::Header, 0, None),
None => {}
}
}
} else {
@ -954,7 +1101,7 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
let caption = if compact { "Column Labels".to_string() } else { col_fields.first().map(|f| f.source_col.clone()).unwrap_or_default() };
g.set(cap, l, Value::text(caption), CellKind::Header, 0, None);
}
let levels = cols.len() + usize::from(k > 1);
let levels = cols.len() + usize::from(k > 1 && !on_rows);
let first_level = g.rows.len();
for _ in 0..levels {
g.push_row(RowKind::Header)?;
@ -1069,17 +1216,19 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
&& (k > 1 || parts.is_empty())
{
parts.push(val_name(*vi));
} else if parts.is_empty() {
parts.push("Total".into());
}
Some(((l + j) as u32, parts.join(" - ")))
})
.filter(|_| k > 0)
.collect();
let label_col = if rows.is_empty() {
let label_col = if row_levels == 0 {
None
} else if compact {
Some(0)
} else {
Some(rows.len().saturating_sub(1) as u32)
Some(row_levels.saturating_sub(1) as u32)
};
for line in &lines {
let rk = match line.kind {
@ -1103,8 +1252,9 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
continue;
}
let rp = line.path.as_slice();
for (j, (si, vi)) in data_cols.iter().enumerate() {
let (Some(spec), Some(vi)) = (specs.get(*si), vi) else { continue };
for (j, (si, col_vi)) in data_cols.iter().enumerate() {
let line_vi = col_vi.or(line.vi);
let (Some(spec), Some(vi)) = (specs.get(*si), &line_vi) else { continue };
let Some((_, vdef)) = vals.get(*vi) else { continue };
let cp = spec.path.as_slice();
let Some(raw) = get(rp, cp, *vi) else { continue };

View File

@ -975,6 +975,12 @@ pub struct PivotTable {
pub last_range: Option<RangeRef>,
/// Calculated fields, usable in the Values area by name.
pub calculated_fields: Vec<PivotCalcField>,
/// The Σ Values pseudo-field (it exists with two or more value fields) sits in Rows rather
/// than Columns (Excel's "values on rows").
pub values_on_rows: bool,
/// Position of Σ Values among the row fields (`None` = after the last). Only used with
/// `values_on_rows`: in Columns Σ Values always comes after the last column field.
pub values_position: Option<usize>,
}
/// A calculated field: a formula over other fields (`=Sales*0.1`, `='Unit Price'*Units`). As in
@ -1007,6 +1013,8 @@ impl Default for PivotTable {
show_headers: true,
last_range: None,
calculated_fields: vec![],
values_on_rows: false,
values_position: None,
}
}
}

View File

@ -64,13 +64,26 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) {
})
.unwrap_or_default()
};
// Σ Values (two or more value fields) sits in Rows or Columns; it moves only between them.
const VALUES_FIELD: &str = "Σ Values";
let values_key = if info["valuesOnRows"].as_bool().unwrap_or(false) { "rows" } else { "columns" };
let values_at = info["valuesPosition"].as_u64().map(|p| p as usize);
let area_with_values = |key: &str| -> Vec<String> {
let mut v = area(key);
if key == values_key && area("values").len() > 1 {
let at = values_at.unwrap_or(v.len()).min(v.len());
v.insert(at, VALUES_FIELD.to_string());
}
v
};
ui.columns(2, |cols| {
for (i, (key, label)) in [("filters", "Filters"), ("columns", "Columns"), ("rows", "Rows"), ("values", "Values")].iter().enumerate() {
let ui = &mut cols[i % 2];
ui.label(egui::RichText::new(*label).strong());
egui::Frame::NONE.fill(t.window).corner_radius(4.0).inner_margin(4.0).show(ui, |ui| {
ui.set_min_size(vec2(ui.available_width(), 60.0));
for f in area(key) {
for f in area_with_values(key) {
let is_values = f == VALUES_FIELD;
let b = ui.add(
egui::Button::new(egui::RichText::new(format!("{f} ▾")).font(theme::ui_font(12.0)))
.fill(Color32::WHITE)
@ -83,10 +96,13 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) {
("filters", "Move to Report Filter"),
("values", "Move to Values"),
] {
if to != *key && ui.button(l).clicked() {
if to != *key && (!is_values || matches!(to, "rows" | "columns")) && ui.button(l).clicked() {
app.run_or_alert("pivot.moveField", json!({"pivot": name, "field": f, "from": key, "to": to}));
}
}
if is_values {
return;
}
if *key == "values" {
ui.separator();
for (func, l) in [("sum", "Sum"), ("count", "Count"), ("average", "Average"), ("max", "Max"), ("min", "Min")] {

View File

@ -810,6 +810,9 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn
PivotLayout::Outline => s.push_str(" compact=\"0\" compactData=\"0\" outline=\"1\" outlineData=\"1\""),
PivotLayout::Tabular => s.push_str(" compact=\"0\" compactData=\"0\""),
}
if pt.values_on_rows {
s.push_str(" dataOnRows=\"1\"");
}
if !pt.show_headers {
s.push_str(" showHeaders=\"0\"");
}
@ -826,13 +829,20 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn
let end = pt.last_range.map(|r| r.end).unwrap_or(a);
let loc = RangeRef::new(a, CellRef::new(end.row.max(a.row), end.col.max(a.col)));
let k = vals.len();
let first_data_row = if col_idx.is_empty() { 1 } else { 1 + col_idx.len() + usize::from(k > 1) };
let first_data_col = if row_idx.is_empty() && col_idx.is_empty() {
// Σ Values (two or more value fields): its index among the written fields of its axis.
let (row_map, _) = c.axis_map.split_at(pt.rows.len().min(c.axis_map.len()));
let values_at = |map: &[Option<usize>], n: usize| pt.values_position.map_or(n, |p| map.iter().take(p).filter(|x| x.is_some()).count());
let row_values = (k > 1 && pt.values_on_rows).then(|| values_at(row_map, row_idx.len()));
// In Columns Σ Values comes after the last field: the column layout doesn't place it elsewhere.
let col_values = (k > 1 && !pt.values_on_rows).then_some(col_idx.len());
let first_data_row = if col_idx.is_empty() { 1 } else { 1 + col_idx.len() + usize::from(col_values.is_some()) };
let row_levels = row_idx.len() + usize::from(row_values.is_some());
let first_data_col = if row_levels == 0 && col_idx.is_empty() {
0
} else if compact {
1
} else {
row_idx.len().max(1)
row_levels.max(1)
};
let _ = write!(s, "<location ref=\"{}\" firstHeaderRow=\"1\" firstDataRow=\"{first_data_row}\" firstDataCol=\"{first_data_col}\"", loc.a1());
if !pages.is_empty() {
@ -922,24 +932,8 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn
}
s.push_str("</pivotFields>");
if !row_idx.is_empty() {
let _ = write!(s, "<rowFields count=\"{}\">", row_idx.len());
for i in &row_idx {
let _ = write!(s, "<field x=\"{i}\"/>");
}
s.push_str("</rowFields>");
}
let values_field = k > 1;
if !col_idx.is_empty() || values_field {
let _ = write!(s, "<colFields count=\"{}\">", col_idx.len() + usize::from(values_field));
for i in &col_idx {
let _ = write!(s, "<field x=\"{i}\"/>");
}
if values_field {
s.push_str("<field x=\"-2\"/>");
}
s.push_str("</colFields>");
}
axis_fields_xml(&mut s, "rowFields", &row_idx, row_values);
axis_fields_xml(&mut s, "colFields", &col_idx, col_values);
if !pages.is_empty() {
let _ = write!(s, "<pageFields count=\"{}\">", pages.len());
for (i, f) in &pages {
@ -1011,6 +1005,25 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn
s
}
/// `<rowFields>`/`<colFields>`: the axis's cache field indices, with Σ Values (`-2`) at `values_at`.
fn axis_fields_xml(s: &mut String, tag: &str, idx: &[usize], values_at: Option<usize>) {
let n = idx.len() + usize::from(values_at.is_some());
if n == 0 {
return;
}
let _ = write!(s, "<{tag} count=\"{n}\">");
for (j, i) in idx.iter().enumerate() {
if values_at == Some(j) {
s.push_str("<field x=\"-2\"/>");
}
let _ = write!(s, "<field x=\"{i}\"/>");
}
if values_at.is_some_and(|p| p >= idx.len()) {
s.push_str("<field x=\"-2\"/>");
}
let _ = write!(s, "</{tag}>");
}
// ================================================================ reading
/// A cache field as read.
@ -1253,18 +1266,21 @@ fn read_table(cx: &mut Ctx<'_>, x: &El, cache: &CacheIn, id: u32) -> Option<Pivo
pt.grand_totals_cols = x.flag("colGrandTotals", true);
pt.show_headers = x.flag("showHeaders", true);
pt.style = x.child("pivotTableStyleInfo").and_then(|s| s.attr("name")).unwrap_or("").to_string();
if x.flag("dataOnRows", false) {
cx.warn(format!("PivotTable \"{}\": values on rows are shown on columns", pt.name));
}
if x.child("calculatedItems").is_some() || x.path(&["filters", "filter"]).is_some() {
cx.warn(format!("PivotTable \"{}\": calculated items and label/value filters are not supported", pt.name));
}
let mut filters: Vec<PivotFilter> = Vec::new();
let axis = |cx: &mut Ctx<'_>, list: &[i64], filters: &mut Vec<PivotFilter>| -> Vec<PivotField> {
// (fields, position of Σ Values (`-2`) among them)
let axis = |cx: &mut Ctx<'_>, list: &[i64], filters: &mut Vec<PivotFilter>| -> (Vec<PivotField>, Option<usize>) {
let mut v = Vec::new();
let mut values_at = None;
for &i in list {
if i == -2 {
values_at = Some(v.len());
continue;
}
if i < 0 {
continue;
}
@ -1290,7 +1306,7 @@ fn read_table(cx: &mut Ctx<'_>, x: &El, cache: &CacheIn, id: u32) -> Option<Pivo
}
v.push(f);
}
v
(v, values_at)
};
let mut page_filters = Vec::new();
for p in &pages {
@ -1314,8 +1330,15 @@ fn read_table(cx: &mut Ctx<'_>, x: &El, cache: &CacheIn, id: u32) -> Option<Pivo
page_filters.push(PivotFilter { source_col: name, selected });
}
let mut axis_filters = Vec::new();
pt.rows = axis(cx, &row_x, &mut axis_filters);
pt.columns = axis(cx, &col_x, &mut axis_filters);
let (rows, rows_values) = axis(cx, &row_x, &mut axis_filters);
let (columns, cols_values) = axis(cx, &col_x, &mut axis_filters);
pt.rows = rows;
pt.columns = columns;
// Σ Values: where its `-2` entry is, else `dataOnRows` and `dataPosition`; after the last
// field by default. In Columns it always comes last, as the column layout puts it there.
pt.values_on_rows = rows_values.is_some() || (cols_values.is_none() && x.flag("dataOnRows", false));
pt.values_position =
if pt.values_on_rows { rows_values.or_else(|| x.attr_u32("dataPosition").map(|p| p as usize)).filter(|p| *p < pt.rows.len()) } else { None };
filters.extend(page_filters);
filters.extend(axis_filters);
pt.filters = filters;

View File

@ -109,6 +109,8 @@ fn workbook() -> Workbook {
show_headers: false,
last_range: Some(rr("A2:H12")),
calculated_fields: vec![],
values_on_rows: false,
values_position: None,
};
// Pivot 2: table source, date grouping on two levels, show-values-as, multi-select page
// filter and a hidden-items filter on a column field.
@ -166,6 +168,8 @@ fn workbook() -> Workbook {
show_headers: true,
last_range: Some(rr("K2:Z30")),
calculated_fields: vec![],
values_on_rows: false,
values_position: None,
};
// Pivot 3: defaults, defined-name source, compact.
let p3 = PivotTable {
@ -508,3 +512,76 @@ fn calculated_fields_round_trip() {
assert_eq!(a, b, "pivot {} differs", a.name);
}
}
/// The `x` attributes of an axis list (`rowFields`/`colFields`) in a pivotTable part.
fn axis_xs(table: &str, tag: &str) -> Vec<i64> {
let Some(start) = table.find(&format!("<{tag} ")) else { return vec![] };
let end = table[start..].find(&format!("</{tag}>")).map_or(table.len(), |e| start + e);
table[start..end].split("<field x=\"").skip(1).filter_map(|s| s.split('"').next()?.parse().ok()).collect()
}
#[test]
fn values_on_rows_round_trip() {
let mut wb = workbook();
{
let p = wb.sheet_mut(1).unwrap().pivots.get_mut(1).unwrap();
p.values_on_rows = true;
p.values_position = Some(1);
}
let bytes = write_xlsx(&wb).unwrap();
let parts = unzip(&bytes);
let t2 = String::from_utf8(parts.get("xl/pivotTables/pivotTable2.xml").unwrap().clone()).unwrap();
assert!(t2.contains(" dataOnRows=\"1\""), "{t2}");
// Σ Values (-2) between the two row fields; Columns keep only Product.
let rows = axis_xs(&t2, "rowFields");
assert_eq!(rows.len(), 3, "{t2}");
assert_eq!(rows[1], -2, "{t2}");
assert!(!axis_xs(&t2, "colFields").contains(&-2), "{t2}");
assert!(t2.contains("firstDataRow=\"2\" firstDataCol=\"3\""), "{t2}");
let (back, report) = read_xlsx(&bytes).unwrap();
assert!(report.warnings.iter().all(|w| !w.to_lowercase().contains("pivot")), "{:?}", report.warnings);
assert_eq!(back.sheet(1).unwrap().pivots, wb.sheet(1).unwrap().pivots);
// After the last row field.
wb.sheet_mut(1).unwrap().pivots[1].values_position = None;
let bytes = write_xlsx(&wb).unwrap();
let t2 = String::from_utf8(unzip(&bytes).get("xl/pivotTables/pivotTable2.xml").unwrap().clone()).unwrap();
assert_eq!(axis_xs(&t2, "rowFields").last(), Some(&-2), "{t2}");
let (back, _) = read_xlsx(&bytes).unwrap();
assert_eq!(back.sheet(1).unwrap().pivots, wb.sheet(1).unwrap().pivots);
// Σ Values in Columns is written after the last column field, its only place in the layout.
{
let p = wb.sheet_mut(1).unwrap().pivots.get_mut(1).unwrap();
p.values_on_rows = false;
p.values_position = Some(0);
}
let bytes = write_xlsx(&wb).unwrap();
let t2 = String::from_utf8(unzip(&bytes).get("xl/pivotTables/pivotTable2.xml").unwrap().clone()).unwrap();
assert!(!t2.contains("dataOnRows"), "{t2}");
assert_eq!(axis_xs(&t2, "colFields").last(), Some(&-2), "{t2}");
let (back, _) = read_xlsx(&bytes).unwrap();
assert_eq!(back.sheet(1).unwrap().pivots[1].values_position, None);
}
#[test]
fn values_ahead_of_column_fields_read_last() {
let table = r#"<pivotTableDefinition name="ValuesFirst" cacheId="1"><location ref="C1:F4"/><pivotFields><pivotField axis="axisCol"><items><item x="0"/><item x="1"/><item t="default"/></items></pivotField><pivotField dataField="1"/></pivotFields><colFields count="2"><field x="-2"/><field x="0"/></colFields><dataFields><dataField name="Sum of V" fld="1"/><dataField name="Count of V" fld="1" subtotal="count"/></dataFields></pivotTableDefinition>"#;
let (wb, _) = read_xlsx(&with_pivot(table, Some(GOOD_CACHE))).unwrap();
let pt = &wb.sheet(0).unwrap().pivots[0];
assert_eq!((pt.columns.len(), pt.values.len()), (1, 2));
assert!(!pt.values_on_rows);
assert_eq!(pt.values_position, None);
}
#[test]
fn data_on_rows_with_one_value_field_reads_without_warning() {
let table = r#"<pivotTableDefinition name="OneValue" cacheId="1" dataOnRows="1"><location ref="C1:D4"/><pivotFields><pivotField axis="axisRow"><items><item x="0"/><item x="1"/><item t="default"/></items></pivotField><pivotField dataField="1"/></pivotFields><rowFields><field x="0"/></rowFields><dataFields><dataField name="Total" fld="1"/></dataFields></pivotTableDefinition>"#;
let (wb, report) = read_xlsx(&with_pivot(table, Some(GOOD_CACHE))).unwrap();
assert!(report.warnings.iter().all(|w| !w.contains("values on rows")), "{:?}", report.warnings);
let pt = &wb.sheet(0).unwrap().pivots[0];
assert!(pt.values_on_rows);
assert_eq!(pt.values_position, None);
let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
assert!(back.sheet(0).unwrap().pivots[0].values_on_rows);
}

View File

@ -1,6 +1,6 @@
# Gaps
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (direct desktop printing through craft-print) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (§8: PivotTable values on rows) · **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
@ -79,7 +79,8 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
### 8. PivotTables: no slicers, calculated items, PivotCharts; unverified in Excel (F)
- **Evidence:** `parity-checklist.md` missing `insert.slicer`, `insert.timeline`,
`insert.pivotChart`, `table.insertSlicer`; calculated items and GETPIVOTDATA missing (calculated fields done).
`insert.pivotChart`, `table.insertSlicer`; calculated items and GETPIVOTDATA missing (calculated fields
and values on rows done).
- **Impact:** pivots are the analyst's main tool.
- **Estimate:** 20–30 h for beta (25–40 h full).
@ -164,6 +165,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | §8: values on rows (Σ Values in Rows, `dataOnRows`) laid out, moved and round-tripped |
| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. |
| 2026-10-11 | minor | §6: iterative calculation for circular references landed (Maximum Iterations, Maximum Change) |
| 2026-10-11 | minor | §15: trim-reference operators (`A1:.E10`, `A1.:E10`, `A1.:.E10`) parse and evaluate |

View File

@ -1,6 +1,6 @@
# Target-app parity: GridCraft vs Microsoft Excel
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (stability: command-sequence fuzzing against undo/redo, XLSX round-trip and full-recalc invariants) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (PivotTables: values on rows) · **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, calculated fields, XLSX round-trip. Missing: slicers, timelines, calculated 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, values on rows or columns, date grouping, show-values-as, refresh, calculated fields, XLSX round-trip. Missing: slicers, timelines, calculated items, PivotCharts, 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, Analysis ToolPak (13 of 19 tools, no charts), Solver (Simplex LP, a local nonlinear method and Evolutionary; Answer and Limits reports; no Sensitivity report or options dialog). Missing: Forecast Sheet, Analyze Data |
@ -301,7 +301,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land
average, colour), tables with totals rows, remove duplicates, text to columns, data validation
with error alerts, grouping/outline, subtotals.
- PivotTables: Insert/Recommended PivotTables, field list pane, compact/outline/tabular layouts,
date grouping, sort, filters, show-values-as, refresh, and XLSX round-trip.
values on rows or columns, date grouping, sort, filters, show-values-as, refresh, and XLSX round-trip.
- Page layout & print: Page Break Preview, print settings, PDF export and printing.
- What-if: Goal Seek, scenarios, data tables. Spelling (system word list) and thesaurus.
- Draw: pen, highlighter, eraser, ink strokes and Ink to Shape. Task panes: Comments (threaded
@ -332,6 +332,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | PivotTables: values on rows (Σ Values in Rows at any position, all three layouts; `pivot.moveField` and the field pane move it; `dataOnRows` round-trips) (no score change) |
| 2026-10-11 | minor | Stability: command-sequence fuzz harness (`crates/engine/tests/command_sequence_fuzz.rs`) checks random command sequences against undo/redo, XLSX round-trip, full-recalc and structure invariants; the sequence bugs it found are fixed (no score change) |
| 2026-10-11 | minor | Password-encrypted workbooks open (#11): Essentials file-opening work 24–36 h |
| 2026-10-11 | minor | Worksheet functions 505 / 523 (96.6%; 496 working): ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD |