///|
pub struct PivotTableField {
data : String
name : String
subtotal : String
num_fmt : Int
compact : Bool
outline : Bool
show_all : Bool
insert_blank_row : Bool
default_subtotal : Bool
} derive(Debug)
///|
pub fn PivotTableField::new(
data : String,
name? : String = "",
subtotal? : String = "Sum",
num_fmt? : Int = 0,
compact? : Bool = false,
outline? : Bool = false,
show_all? : Bool = false,
insert_blank_row? : Bool = false,
default_subtotal? : Bool = false,
) -> PivotTableField raise XlsxError {
if data == "" {
raise InvalidPivotTable(msg="pivot field data empty")
}
{
data,
name,
subtotal,
num_fmt,
compact,
outline,
show_all,
insert_blank_row,
default_subtotal,
}
}
///|
/// Canonical dataField subtotal enum values accepted by the OOXML schema.
let pivot_subtotal_enums : Array[String] = [
"average", "count", "countNums", "max", "min", "product", "stdDev", "stdDevp",
"sum", "var", "varp",
]
///|
/// Normalizes a user-provided subtotal to its canonical schema spelling via
/// case-insensitive match, falling back to "sum" like Excelize.
fn normalize_pivot_subtotal(value : String) -> String {
let lowered = value.to_lower()
for enum_value in pivot_subtotal_enums {
if enum_value.to_lower() == lowered {
return enum_value
}
}
"sum"
}
///|
/// Returns the number format ID to emit for a pivot data field: built-in
/// format IDs (including the language-specific 27-36 and 50-81 ranges) pass
/// through, anything else maps to 0 and is omitted, mirroring Excelize.
fn pivot_data_field_num_fmt_id(num_fmt : Int) -> Int {
match num_fmt {
0..=4 | 9..=22 | 37..=49 => num_fmt
27..=36 | 50..=81 => num_fmt
_ => 0
}
}
///|
/// Truncates a pivot field display name to Excelize's MaxFieldLength of
/// 255 UTF-16 code units.
fn pivot_field_display_name(name : String) -> String {
if count_utf16_units(name) > max_field_length {
truncate_utf16_units(name, max_field_length)
} else {
name
}
}
///|
pub(all) struct PivotTableOptions {
data_range : String
pivot_table_range : String
mut name : String
rows : Array[PivotTableField]
columns : Array[PivotTableField]
data : Array[PivotTableField]
filter : Array[PivotTableField]
mut row_grand_totals : Bool
mut col_grand_totals : Bool
mut show_drill : Bool
mut use_auto_formatting : Bool
mut page_over_then_down : Bool
mut merge_item : Bool
mut classic_layout : Bool
mut compact_data : Bool
mut show_error : Bool
mut show_row_headers : Bool
mut show_col_headers : Bool
mut show_row_stripes : Bool
mut show_col_stripes : Bool
mut show_last_column : Bool
mut field_print_titles : Bool
mut item_print_titles : Bool
mut pivot_table_style_name : String
} derive(Debug)
///|
pub fn PivotTableOptions::new(
data_range : String,
pivot_table_range : String,
) -> PivotTableOptions raise XlsxError {
if data_range == "" {
raise InvalidPivotTable(msg="pivot data range empty")
}
if pivot_table_range == "" {
raise InvalidPivotTable(msg="pivot table range empty")
}
{
data_range,
pivot_table_range,
name: "",
rows: [],
columns: [],
data: [],
filter: [],
row_grand_totals: true,
col_grand_totals: true,
show_drill: true,
use_auto_formatting: false,
page_over_then_down: false,
merge_item: false,
classic_layout: false,
compact_data: false,
show_error: false,
show_row_headers: true,
show_col_headers: true,
show_row_stripes: true,
show_col_stripes: false,
show_last_column: false,
field_print_titles: false,
item_print_titles: false,
pivot_table_style_name: "PivotStyleLight16",
}
}
///|
fn split_sheet_range(value : StringView) -> (String, String) raise XlsxError {
let text = value.to_owned()
match text.find("!") {
Some(pos) => {
if pos <= 0 || pos + 1 >= text.length() {
raise InvalidPivotTable(msg="range must be Sheet!A1:B2")
}
let sheet = text[:pos]
let range_ref = text[pos + 1:]
(sheet.to_owned(), range_ref.to_owned())
}
None => raise InvalidPivotTable(msg="range must be Sheet!A1:B2")
}
}
///|
fn write_pivot_cache_definition_xml(
data_sheet : String,
data_ref : String,
fields : ArrayView[String],
) -> String {
let sb = StringBuilder()
sb.write_view("\n")
sb.write_view(
"\n",
)
sb.write_view(" \n")
sb.write_view(" \n")
sb.write_view(" \n")
sb.write_view(" \n")
for name in fields {
sb.write_view(" \n")
sb.write_view(" \n")
sb.write_view(" \n")
}
sb.write_view(" \n")
sb.write_view("")
sb.to_string()
}
///|
/// The `` attributes of a pivot table, all of them derived from the
/// field layout rather than fixed (ECMA-376 Part 1, 18.10.1.49 `location`,
/// `CT_Location`).
priv struct PivotLocation {
/// `firstHeaderRow`: row of the PivotTable header, relative to the top left
/// cell of `ref`.
first_header_row : Int
/// `firstDataRow`: row of the first PivotTable data row, relative to the top
/// left cell of `ref`.
first_data_row : Int
/// `firstDataCol`: column of the first PivotTable data column, relative to
/// the top left cell of `ref`.
first_data_col : Int
/// `rowPageCount` / `colPageCount`: rows and columns the page (filter) field
/// area occupies. Both are `0` (the schema default, omitted from the output)
/// when the pivot has no page fields.
row_page_count : Int
col_page_count : Int
}
///|
/// Derives the `` attributes from the axes a pivot table actually
/// uses. Excel refreshes these pivots on load (`refreshOnLoad="1"` on the cache
/// definition, no cached records), so `` is what tells it how much
/// room the refreshed layout needs; a location that contradicts the fields is
/// what made page-field pivots unplaceable (issue #264).
///
/// Frame of reference: the standard only says `@ref` is "the first row of the
/// PivotTable", but [MS-OI29500]'s implementer note on Part 1 18.10.1.49
/// records that "Office assumes that @ref specifies the range of the PivotTable
/// **excluding** the page fields". The filter block therefore sits *above*
/// `ref` and never shifts the row offsets below it; only `rowPageCount` /
/// `colPageCount` describe it. The same note records that Office bounds
/// `firstHeaderRow` by `firstDataRow`.
///
/// Each attribute, and where its value comes from:
///
/// * `firstDataCol` — width of the row-label area. Compact form collapses every
/// row field into a single shared column; outline and tabular form give each
/// row field its own column. With no row fields at all there is still the one
/// corner column that carries the grand-total label, hence the floor of 1.
///
/// * `firstDataRow` — `firstHeaderRow` plus the height of the column-header
/// block. That block has one row per column field, plus one more row for the
/// implicit "Values" column field Excel materialises when several data fields
/// share the column axis, and is at least one row tall (a pivot with no
/// column fields still prints a header row of data-field captions).
///
/// * `firstHeaderRow` — 1 when a column-axis caption row sits above the header
/// block, which is the case as soon as the column axis carries anything (a
/// column field, or the implicit "Values" field of a multi-data pivot), else
/// 0. This stays within Office's `firstHeaderRow <= firstDataRow` bound
/// because the header block is always at least one row tall.
///
/// * `rowPageCount` / `colPageCount` — the shape of the filter block, written
/// only when the pivot has page fields. Per 18.10.1.49, "by default there is
/// a single column of filter fields per page and the fields occupy as many
/// rows as there are fields", i.e. one column and one row per page field.
/// `pageOverThenDown` lays the same fields out across instead of down, which
/// transposes the pair; that transposed form follows from the attribute's
/// definition but was not checked against a real Excel-authored sample.
fn pivot_location(
row_fields : ArrayView[(Int, PivotTableField)],
col_fields : ArrayView[(Int, PivotTableField)],
page_fields : ArrayView[(Int, PivotTableField)],
data_fields : ArrayView[(Int, PivotTableField)],
opts : PivotTableOptions,
) -> PivotLocation {
// `classic_layout` forces compact="0" outline="0" on every axis field (see
// the pivotField writer below), so the row labels always get their own
// columns there.
let compact_rows = !opts.classic_layout &&
row_fields.length() > 0 &&
row_fields.iter().all(entry => entry.1.compact)
let first_data_col = if compact_rows || row_fields.length() < 1 {
1
} else {
row_fields.length()
}
let values_on_col_axis = data_fields.length() > 1
let col_axis_levels = col_fields.length() +
(if values_on_col_axis { 1 } else { 0 })
let header_rows = if col_axis_levels < 1 { 1 } else { col_axis_levels }
let first_header_row = if col_fields.length() > 0 || values_on_col_axis {
1
} else {
0
}
let page_count = page_fields.length()
let (row_page_count, col_page_count) = if page_count == 0 {
(0, 0)
} else if opts.page_over_then_down {
(1, page_count)
} else {
(page_count, 1)
}
{
first_header_row,
first_data_row: first_header_row + header_rows,
first_data_col,
row_page_count,
col_page_count,
}
}
///|
fn write_pivot_table_definition_xml(
name : String,
cache_id : Int,
pivot_ref : String,
field_names : ArrayView[String],
row_fields : ArrayView[(Int, PivotTableField)],
col_fields : ArrayView[(Int, PivotTableField)],
page_fields : ArrayView[(Int, PivotTableField)],
data_fields : ArrayView[(Int, PivotTableField)],
opts : PivotTableOptions,
) -> String {
let sb = StringBuilder()
sb.write_view("\n")
sb.write_view(
"\n")
let location = pivot_location(
row_fields, col_fields, page_fields, data_fields, opts,
)
sb.write_view(" 0 || location.col_page_count > 0 {
sb.write_view(" rowPageCount=\"\{location.row_page_count}\"")
sb.write_view(" colPageCount=\"\{location.col_page_count}\"")
}
sb.write_view("/>\n")
let row_opts : Map[Int, PivotTableField] = Map([])
for entry in row_fields {
let (idx, field) = entry
row_opts[idx] = field
}
let col_opts : Map[Int, PivotTableField] = Map([])
for entry in col_fields {
let (idx, field) = entry
col_opts[idx] = field
}
let page_opts : Map[Int, PivotTableField] = Map([])
for entry in page_fields {
let (idx, field) = entry
page_opts[idx] = field
}
let data_opts : Map[Int, PivotTableField] = Map([])
for entry in data_fields {
let (idx, field) = entry
data_opts[idx] = field
}
sb.write_view(" \n")
for i, _fname in field_names {
sb.write_view(" {
let pivot_name = if field.name == "" { field.data } else { field.name }
sb.write_view(" name=\"")
sb.write_view(escape_xml_attr(pivot_field_display_name(pivot_name)))
sb.write_view("\" axis=\"axisRow\"")
let compact = if opts.classic_layout { false } else { field.compact }
let outline = if opts.classic_layout { false } else { field.outline }
sb.write_view(" compact=\"")
sb.write_view(bool_attr(compact))
sb.write_view("\" outline=\"")
sb.write_view(bool_attr(outline))
sb.write_view("\" showAll=\"")
sb.write_view(bool_attr(field.show_all))
sb.write_view("\" defaultSubtotal=\"")
sb.write_view(bool_attr(field.default_subtotal))
sb.write_view("\"")
if field.insert_blank_row {
sb.write_view(" insertBlankRow=\"1\"")
}
match data_opts.get(i) {
Some(_) => sb.write_view(" dataField=\"1\"")
None => ()
}
sb.write_view(">")
if field.default_subtotal {
sb.write_view(" ")
} else {
sb.write_view(" ")
}
sb.write_view("\n")
continue
}
None => ()
}
match col_opts.get(i) {
Some(field) => {
let pivot_name = if field.name == "" { field.data } else { field.name }
sb.write_view(" name=\"")
sb.write_view(escape_xml_attr(pivot_field_display_name(pivot_name)))
sb.write_view("\" axis=\"axisCol\"")
let compact = if opts.classic_layout { false } else { field.compact }
let outline = if opts.classic_layout { false } else { field.outline }
sb.write_view(" compact=\"")
sb.write_view(bool_attr(compact))
sb.write_view("\" outline=\"")
sb.write_view(bool_attr(outline))
sb.write_view("\" showAll=\"")
sb.write_view(bool_attr(field.show_all))
sb.write_view("\" defaultSubtotal=\"")
sb.write_view(bool_attr(field.default_subtotal))
sb.write_view("\"")
if field.insert_blank_row {
sb.write_view(" insertBlankRow=\"1\"")
}
match data_opts.get(i) {
Some(_) => sb.write_view(" dataField=\"1\"")
None => ()
}
sb.write_view(">")
if field.default_subtotal {
sb.write_view(" ")
} else {
sb.write_view(" ")
}
sb.write_view("\n")
continue
}
None => ()
}
match page_opts.get(i) {
Some(field) => {
let pivot_name = if field.name == "" { field.data } else { field.name }
sb.write_view(" name=\"")
sb.write_view(escape_xml_attr(pivot_field_display_name(pivot_name)))
sb.write_view("\" axis=\"axisPage\"")
sb.write_view(" showAll=\"")
sb.write_view(bool_attr(field.show_all))
sb.write_view("\"")
match data_opts.get(i) {
Some(_) => sb.write_view(" dataField=\"1\"")
None => ()
}
sb.write_view(
"> \n",
)
continue
}
None => ()
}
match data_opts.get(i) {
Some(_) => {
if opts.classic_layout {
sb.write_view(" compact=\"0\" outline=\"0\"")
}
sb.write_view(" dataField=\"1\" showAll=\"0\"/>\n")
continue
}
None => ()
}
if opts.classic_layout {
sb.write_view(" compact=\"0\" outline=\"0\"")
}
sb.write_view(" showAll=\"0\"/>\n")
}
sb.write_view(" \n")
if row_fields.length() > 0 {
sb.write_view(" \n")
for entry in row_fields {
let (idx, _field) = entry
sb.write_view(" \n")
}
sb.write_view(" \n")
sb.write_view(" \n")
}
if col_fields.length() > 0 {
sb.write_view(" \n")
for entry in col_fields {
let (idx, _field) = entry
sb.write_view(" \n")
}
sb.write_view(" \n")
sb.write_view(" \n")
}
if page_fields.length() > 0 {
sb.write_view(" \n")
for entry in page_fields {
let (idx, _field) = entry
sb.write_view(" \n")
}
sb.write_view(" \n")
}
if data_fields.length() > 0 {
sb.write_view(" \n")
for entry in data_fields {
let (idx, field) = entry
sb.write_view(" \n")
}
sb.write_view(" \n")
}
sb.write_view(" \n")
sb.write_view("")
sb.to_string()
}