Merge pull request #718 from neilcawse:fix/pivot-values-on-rows
This commit is contained in:
commit
98c85c6de4
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")] {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue