diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index d44f2e0..8a441fa 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -87,7 +87,7 @@ pub fn specs() -> Vec { "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 { + 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) { + 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) -> Result { + 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 { 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 { 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::>(), + "valuesOnRows": pt.values_on_rows, + "valuesPosition": pt.values_position, }) } @@ -890,18 +948,22 @@ fn move_field(s: &mut Session, p: &Json) -> Result { 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 { } 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(); diff --git a/crates/engine/src/pivot.rs b/crates/engine/src/pivot.rs index a53ee40..6146bd4 100644 --- a/crates/engine/src/pivot.rs +++ b/crates/engine/src/pivot.rs @@ -560,6 +560,8 @@ struct Line { values: bool, /// (column, value, indent, number format) labels: Vec<(usize, Value, u8, Option)>, + /// The value field of the line when Σ Values sits in Rows (`None`: one per data column). + vi: Option, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -579,6 +581,10 @@ struct Ctx<'a> { rows: Vec<&'a PivotField>, row_items: Vec, row_fmts: Vec, + /// Depth of Σ Values among the row fields when it sits in Rows (two or more value fields). + vpos: Option, + /// Value field captions. + val_names: Vec, } 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, out: &mut Vec) { + /// (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) -> 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) { + 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, out: &mut Vec) { + 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, prefix: &mut Vec, out: &mut Vec) { + 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)], out: &mut Vec, ) { + for (vi, name) in self.val_names.iter().enumerate() { + let mut labels: Vec<(usize, Value, u8, Option)> = 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, + prefix: &mut Vec, + pending: &[(usize, Value, u8, Option)], + out: &mut Vec, + ) { + 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)> = 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 = (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 = rows.iter().map(|_| FieldItems::default()).collect(); @@ -827,24 +955,32 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result = 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 = 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 = 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| 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 = Vec::new(); @@ -858,7 +994,10 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result)> = 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 Result = 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 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 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 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, /// Calculated fields, usable in the Values area by name. pub calculated_fields: Vec, + /// 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, } /// 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, } } } diff --git a/crates/ui-egui/src/pivot_pane.rs b/crates/ui-egui/src/pivot_pane.rs index 14d69db..0657026 100644 --- a/crates/ui-egui/src/pivot_pane.rs +++ b/crates/ui-egui/src/pivot_pane.rs @@ -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 { + 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")] { diff --git a/crates/xlsx/src/pivot.rs b/crates/xlsx/src/pivot.rs index 1d16ab3..609ae53 100644 --- a/crates/xlsx/src/pivot.rs +++ b/crates/xlsx/src/pivot.rs @@ -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], 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, ""); - if !row_idx.is_empty() { - let _ = write!(s, "", row_idx.len()); - for i in &row_idx { - let _ = write!(s, ""); - } - s.push_str(""); - } - let values_field = k > 1; - if !col_idx.is_empty() || values_field { - let _ = write!(s, "", col_idx.len() + usize::from(values_field)); - for i in &col_idx { - let _ = write!(s, ""); - } - if values_field { - s.push_str(""); - } - s.push_str(""); - } + 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, "", 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 } +/// ``/``: 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) { + 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(""); + } + let _ = write!(s, ""); + } + if values_at.is_some_and(|p| p >= idx.len()) { + s.push_str(""); + } + let _ = write!(s, ""); +} + // ================================================================ reading /// A cache field as read. @@ -1253,18 +1266,21 @@ fn read_table(cx: &mut Ctx<'_>, x: &El, cache: &CacheIn, id: u32) -> Option = Vec::new(); - let axis = |cx: &mut Ctx<'_>, list: &[i64], filters: &mut Vec| -> Vec { + // (fields, position of Σ Values (`-2`) among them) + let axis = |cx: &mut Ctx<'_>, list: &[i64], filters: &mut Vec| -> (Vec, Option) { 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, x: &El, cache: &CacheIn, id: u32) -> Option 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 { + let Some(start) = table.find(&format!("<{tag} ")) else { return vec![] }; + let end = table[start..].find(&format!("")).map_or(table.len(), |e| start + e); + table[start..end].split(""#; + 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#""#; + 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); +} diff --git a/docs/gaps.md b/docs/gaps.md index 667dca4..02628cb 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 45ce879..351081b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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 |