mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-10 04:19:48 -06:00
Merge origin/main (25881a8e) into w/objects
ROADMAP.md, docs/chart-parity.md, docs/gaps.md, docs/target-app-parity.md, docs/ui-parity.md: the header change notes combine this branch's note with main's, dated today; main's revision and progress rows stay and this branch's rows are re-dated on top. Code merged without conflict.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -38,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Chart Element",
|
||||
["Chart Design", "Chart Layouts"],
|
||||
None,
|
||||
"{chart?, element: title|legend|dataLabels|gridlines|axisTitles|smooth|trendline, on?: bool, position?: bottom|top|left|right; trendline: series?: index (default all), type?: linear|exponential|linearForecast|movingAverage|logarithmic|power|polynomial, order?: 2..6, period?: 2..255, forward?, backward?, displayEquation?, displayRSquared?}",
|
||||
"{chart?, element: title|legend|dataLabels|dataTable|gridlines|axisTitles|smooth|trendline, on?: bool, position?: bottom|top|left|right; dataTable: dataTableKeys?: bool, dataTableHorizontalBorder?: bool, dataTableVerticalBorder?: bool, dataTableOutline?: bool; trendline: series?: index (default all), type?: linear|exponential|linearForecast|movingAverage|logarithmic|power|polynomial, order?: 2..6, period?: 2..255, forward?, backward?, displayEquation?, displayRSquared?}",
|
||||
has_doc,
|
||||
add_element
|
||||
),
|
||||
|
||||
@@ -43,6 +43,7 @@ pub(super) fn add_element(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
"dataLabels" => c.data_labels = on,
|
||||
"dataTable" => crate::cmd::insert::update_data_table(c, p, Some(on))?,
|
||||
"gridlines" => c.gridlines = on,
|
||||
"axisTitles" => {
|
||||
c.x_title = on.then(|| c.x_title.clone().unwrap_or_else(|| "Axis Title".into()));
|
||||
|
||||
@@ -424,6 +424,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
series,
|
||||
legend: LegendPos::Bottom,
|
||||
data_labels: false,
|
||||
data_table: None,
|
||||
gridlines: true,
|
||||
style: 1,
|
||||
x_title: None,
|
||||
|
||||
@@ -69,6 +69,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!(query "formulas.tracePrecedents", "Trace Precedents", ["Formulas", "Formula Auditing"], None, "{cell?} → ranges", has_doc, trace_precedents),
|
||||
cmd!(query "formulas.traceDependents", "Trace Dependents", ["Formulas", "Formula Auditing"], None, "{cell?} → cells", has_doc, trace_dependents),
|
||||
cmd!(
|
||||
query "formulas.traceError",
|
||||
"Trace Error",
|
||||
["Formulas", "Formula Auditing"],
|
||||
None,
|
||||
"{cell?} → {sheet, cell, error, source: {sheet, cell}, arrows: [{from: {sheet, range}, to: {sheet, cell}, kind: \"error\"|\"precedent\"}], truncated}: from the cell, follows the first reference that shows the same error (in a range, its first cell showing it) back to the cell the error starts in, `source`. `error` arrows link the cells on the way; `precedent` arrows lead from the source's own references into it. A cell without an error gives `error` and `source` null and no arrows. A chain longer than 10,000 links stops there with `truncated` true and `source` null.",
|
||||
has_doc,
|
||||
trace_error
|
||||
),
|
||||
cmd!(noundo "formulas.selectPrecedents", "Select Precedents", ["Formulas", "Formula Auditing"], Some("Ctrl+["), "{cell?, all?: true = every level (Ctrl+Shift+[)} → selects the precedent cells", has_doc, select_precedents),
|
||||
cmd!(noundo "formulas.selectDependents", "Select Dependents", ["Formulas", "Formula Auditing"], Some("Ctrl+]"), "{cell?, all?: true = every level (Ctrl+Shift+])}", has_doc, select_dependents),
|
||||
cmd!(noundo "formulas.removeArrows", "Remove Arrows", ["Formulas", "Formula Auditing"], None, "{}", has_doc, |_, _| ok()),
|
||||
@@ -653,6 +662,79 @@ fn trace_dependents(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Ok(Json::Array(v))
|
||||
}
|
||||
|
||||
/// Trace Error goes back at most this many links; a longer chain stops there.
|
||||
const TRACE_ERROR_LINKS: usize = 10_000;
|
||||
|
||||
/// Trace Error: from the cell, the first reference in its formula that shows the same error
|
||||
/// (in a range, the range's first cell showing it, row by row), then that cell's, and so on to the
|
||||
/// cell the error starts in: one none of whose references shows it, or one without references.
|
||||
/// Arrows link the cells on the way, and lead from the source's own references into it.
|
||||
fn trace_error(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
use std::collections::HashSet;
|
||||
let d = s.doc()?;
|
||||
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
|
||||
let sheet = d.wb.active_sheet();
|
||||
let name = |si: usize| d.wb.sheet(si).map(|sh| sh.name.clone());
|
||||
let cell = |(si, c): (usize, CellRef)| json!({"sheet": name(si), "cell": c.a1()});
|
||||
let Some(error) = d.wb.sheet(sheet).and_then(|sh| sh.value(at).as_error()) else {
|
||||
return Ok(json!({"sheet": name(sheet), "cell": at.a1(), "error": null, "source": null, "arrows": [], "truncated": false}));
|
||||
};
|
||||
// A value spilled by a dynamic array comes from its anchor's formula.
|
||||
let precedents = |(si, c): (usize, CellRef)| {
|
||||
let anchor = d.wb.sheet(si).filter(|sh| sh.cell(c).is_none_or(|x| x.formula.is_none())).and_then(|sh| sh.spill_anchor(c));
|
||||
d.calc.graph.precedents_of((si, anchor.unwrap_or(c)))
|
||||
};
|
||||
// The walk keeps to cells it hasn't been to, so a circular reference ends it.
|
||||
let mut chain = vec![(sheet, at)];
|
||||
let mut seen: HashSet<(usize, CellRef)> = HashSet::from([(sheet, at)]);
|
||||
let mut truncated = false;
|
||||
let mut last = (sheet, at);
|
||||
while let Some(next) = precedents(last).iter().find_map(|a| first_showing(&d.wb, a, error)) {
|
||||
if !seen.insert(next) {
|
||||
break;
|
||||
}
|
||||
if chain.len() > TRACE_ERROR_LINKS {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
chain.push(next);
|
||||
last = next;
|
||||
}
|
||||
let mut arrows = Vec::new();
|
||||
// A chain cut short hasn't reached its source.
|
||||
if !truncated {
|
||||
let mut drawn = HashSet::new();
|
||||
for a in precedents(last) {
|
||||
if drawn.insert(a) {
|
||||
arrows.push(json!({"from": {"sheet": name(a.sheet), "range": a.range.a1()}, "to": cell(last), "kind": "precedent"}));
|
||||
}
|
||||
}
|
||||
}
|
||||
// From the source outwards, as the error travels.
|
||||
for pair in chain.windows(2).rev() {
|
||||
if let [to, from] = pair {
|
||||
arrows.push(json!({"from": {"sheet": name(from.0), "range": from.1.a1()}, "to": cell(*to), "kind": "error"}));
|
||||
}
|
||||
}
|
||||
let source = if truncated { Json::Null } else { cell(last) };
|
||||
Ok(json!({"sheet": name(sheet), "cell": at.a1(), "error": error.as_str(), "source": source, "arrows": arrows, "truncated": truncated}))
|
||||
}
|
||||
|
||||
/// The first cell of an area, row by row, that shows `error`: a stored cell or a value spilled
|
||||
/// into the area by a dynamic array.
|
||||
fn first_showing(wb: &Workbook, a: &gridcraft_calc::Area, error: CellError) -> Option<(usize, CellRef)> {
|
||||
let sh = wb.sheet(a.sheet)?;
|
||||
let holds = |c: &Cell| !c.value.is_empty() || c.formula.is_some();
|
||||
let stored = sh.cells.iter_range(a.range).find(|(_, c)| holds(c) && c.value.as_error() == Some(error)).map(|(at, _)| at);
|
||||
let spilled = sh
|
||||
.spill
|
||||
.range(a.range.start..)
|
||||
.take_while(|(at, _)| **at <= a.range.end)
|
||||
.find(|(at, v)| a.range.contains(**at) && v.as_error() == Some(error) && !sh.cell(**at).is_some_and(holds))
|
||||
.map(|(at, _)| *at);
|
||||
stored.into_iter().chain(spilled).min().map(|c| (a.sheet, c))
|
||||
}
|
||||
|
||||
/// Excel's Ctrl+[ / Ctrl+]: select the precedent (or dependent) cells. When they all live on
|
||||
/// another sheet, jump there. `all` walks every level (Ctrl+Shift+[ / Ctrl+Shift+]).
|
||||
fn select_trace_cells(s: &mut Session, p: &Json, precedents: bool) -> Result<Json> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Insert tab: tables, charts, sparklines, pictures, shapes, hyperlinks, comments and notes,
|
||||
//! conditional formatting (Home › Styles) and symbols.
|
||||
|
||||
use gridcraft_core::{CellRef, MAX_ROWS, RangeRef};
|
||||
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef};
|
||||
use gridcraft_formula::adjust::{Axis, Edit};
|
||||
use gridcraft_model::{
|
||||
Anchor, BorderLine, BorderStyle, Borders, Cell, CellPicture, CfOperator, CfRule, CfValueKind, Chart, ChartKind, Color, Comment, CondFormat, Fill,
|
||||
@@ -99,7 +99,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Chart Properties",
|
||||
["Chart Design"],
|
||||
None,
|
||||
"{chart?: id, type?, subtype?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
|
||||
"{chart?: id, type?, subtype?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, dataTable?: bool, dataTableKeys?: bool, dataTableHorizontalBorder?: bool, dataTableVerticalBorder?: bool, dataTableOutline?: bool (data tables: column/bar/line/area and compatible combo charts), quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
|
||||
has_doc,
|
||||
chart_set
|
||||
),
|
||||
@@ -242,14 +242,30 @@ fn block(s: &Session, p: &Json) -> Result<RangeRef> {
|
||||
}
|
||||
|
||||
fn chart_areas(s: &Session, p: &Json) -> Result<Vec<RangeRef>> {
|
||||
if p.get("range").is_some() {
|
||||
return target_ranges(s, p);
|
||||
let mut areas = if p.get("range").is_some() {
|
||||
target_ranges(s, p)?
|
||||
} else if s.doc()?.selection.ranges.len() > 1 {
|
||||
s.doc()?.selection.ranges.clone()
|
||||
} else {
|
||||
vec![block(s, p)?]
|
||||
};
|
||||
// Whole columns/rows span a million cells: clip their far edges to the cells in use, so only
|
||||
// a block that really is too large gets refused. Bounded areas keep their extent as given.
|
||||
let sheet = target_sheet(s, p)?;
|
||||
if let Some(used) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
|
||||
// Areas that start outside the used range keep their extent as given.
|
||||
for r in &mut areas {
|
||||
let whole_cols = r.start.row == 0 && r.end.row >= MAX_ROWS - 1;
|
||||
let whole_rows = r.start.col == 0 && r.end.col >= MAX_COLS - 1;
|
||||
if whole_cols && r.start.row <= used.end.row {
|
||||
r.end.row = r.end.row.min(used.end.row);
|
||||
}
|
||||
if whole_rows && r.start.col <= used.end.col {
|
||||
r.end.col = r.end.col.min(used.end.col);
|
||||
}
|
||||
}
|
||||
}
|
||||
let d = s.doc()?;
|
||||
if d.selection.ranges.len() > 1 {
|
||||
return Ok(d.selection.ranges.clone());
|
||||
}
|
||||
Ok(vec![block(s, p)?])
|
||||
Ok(areas)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- tables
|
||||
@@ -828,6 +844,7 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
|
||||
series,
|
||||
legend: if matches!(kind, ChartKind::Pie | ChartKind::Doughnut) { LegendPos::Right } else { LegendPos::Bottom },
|
||||
data_labels: false,
|
||||
data_table: None,
|
||||
gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel),
|
||||
style: 1,
|
||||
x_title: None,
|
||||
@@ -875,7 +892,11 @@ fn chart_set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let Some(c) = sh.charts.get_mut(ci) else { return Ok(Json::Null) };
|
||||
if let Some(t) = str_param(p, "type") {
|
||||
c.kind = chart_kind(t, str_param(p, "subtype").unwrap_or(""));
|
||||
if !c.supports_data_table() {
|
||||
c.data_table = None;
|
||||
}
|
||||
}
|
||||
update_data_table(c, p, bool_param(p, "dataTable"))?;
|
||||
if let Some(method) = quartiles {
|
||||
for series in &mut c.series {
|
||||
series.quartiles = method;
|
||||
@@ -927,6 +948,32 @@ fn chart_set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Shared by Chart Properties and Add Chart Element; no separate UI state.
|
||||
pub(super) fn update_data_table(c: &mut Chart, p: &Json, on: Option<bool>) -> Result<()> {
|
||||
if p.get("dataTable").is_some_and(|v| !v.is_boolean()) {
|
||||
return Err(bad("chart", "`dataTable` must be a boolean"));
|
||||
}
|
||||
if on == Some(false) {
|
||||
c.data_table = None;
|
||||
return Ok(());
|
||||
}
|
||||
let fields = ["dataTableKeys", "dataTableHorizontalBorder", "dataTableVerticalBorder", "dataTableOutline"];
|
||||
if on == Some(true) || fields.iter().any(|k| p.get(k).is_some()) {
|
||||
if !c.supports_data_table() {
|
||||
return Err(bad("chart", "data tables need a column, bar, line, area or compatible combo chart"));
|
||||
}
|
||||
let table = c.data_table.get_or_insert_with(Default::default);
|
||||
for (key, value) in
|
||||
fields.into_iter().zip([&mut table.show_keys, &mut table.horizontal_border, &mut table.vertical_border, &mut table.outline])
|
||||
{
|
||||
if let Some(v) = p.get(key) {
|
||||
*value = v.as_bool().ok_or_else(|| bad("chart", format!("`{key}` must be a boolean")))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn chart_switch(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (si, ci) = find_chart(s, p)?;
|
||||
edit(s, |cx| {
|
||||
@@ -1744,6 +1791,86 @@ fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn chart_from_whole_columns_uses_the_rows_in_use() {
|
||||
// #3114: selecting two whole columns of data refused the chart as "range too large".
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let values: Vec<Vec<String>> = (1..=5).map(|i| vec![i.to_string(), (i * 10).to_string()]).collect();
|
||||
s.execute("range.setValues", json!({"range": "A1:B5", "values": values})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "A:B"})).unwrap();
|
||||
s.execute("insert.chart", json!({"type": "line"})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let chart = d.wb.active().unwrap().charts.last().unwrap();
|
||||
let values: Vec<&str> = chart.series.iter().map(|x| x.values.as_str()).collect();
|
||||
assert_eq!(values, ["Sheet1!$A$1:$A$5", "Sheet1!$B$1:$B$5"]);
|
||||
// A block that really is too large still is.
|
||||
s.execute("cell.set", json!({"cell": "B600000", "input": "1"})).unwrap();
|
||||
assert!(s.execute("insert.chart", json!({"range": "A:B", "type": "line"})).is_err());
|
||||
// A bounded block keeps its extent, even where it runs past the data.
|
||||
s.execute("insert.chart", json!({"range": "A1:A20", "type": "line"})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let chart = d.wb.active().unwrap().charts.last().unwrap();
|
||||
let values: Vec<&str> = chart.series.iter().map(|x| x.values.as_str()).collect();
|
||||
assert_eq!(values, ["Sheet1!$A$1:$A$20"]);
|
||||
// A bounded block entirely below the data keeps its extent.
|
||||
s.execute("insert.chart", json!({"range": "C700000:C700010", "type": "line"})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let chart = d.wb.active().unwrap().charts.last().unwrap();
|
||||
let values: Vec<&str> = chart.series.iter().map(|x| x.values.as_str()).collect();
|
||||
assert_eq!(values, ["Sheet1!$C$700000:$C$700010"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chart_data_table_commands_undo_and_follow_live_values() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "Sales"], ["Q1", 10], ["Q2", 20]]})).unwrap();
|
||||
s.execute("insert.chart", json!({"range": "A1:B3", "type": "column"})).unwrap();
|
||||
s.execute("chart.addElement", json!({"element": "dataTable", "dataTableKeys": false})).unwrap();
|
||||
let get = |s: &Session| s.active().unwrap().wb.active().unwrap().charts[0].clone();
|
||||
let table = get(&s).data_table.unwrap();
|
||||
assert!(!table.show_keys && table.horizontal_border && table.vertical_border && table.outline);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(get(&s).data_table, None);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(get(&s).data_table, Some(table));
|
||||
s.execute("chart.set", json!({"dataTableKeys": true, "dataTableHorizontalBorder": false})).unwrap();
|
||||
assert!(get(&s).data_table.as_ref().unwrap().show_keys);
|
||||
assert!(!get(&s).data_table.as_ref().unwrap().horizontal_border);
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "=12*3"})).unwrap();
|
||||
let d = s.active().unwrap();
|
||||
assert_eq!(gridcraft_chart::resolve(&d.wb, 0, &get(&s)).series[0].values[0], Some(36.0));
|
||||
s.execute("chart.addElement", json!({"element": "dataTable", "on": false})).unwrap();
|
||||
assert_eq!(get(&s).data_table, None);
|
||||
// Old JSON documents have no table field.
|
||||
let mut json = serde_json::to_value(get(&s)).unwrap();
|
||||
json.as_object_mut().unwrap().remove("data_table");
|
||||
assert_eq!(serde_json::from_value::<Chart>(json).unwrap().data_table, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chart_data_table_refuses_unsupported_types_without_changing_history() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Q", "Sales"], ["Q1", 10], ["Q2", 20]]})).unwrap();
|
||||
s.execute("insert.chart", json!({"range": "A1:B3", "type": "pie"})).unwrap();
|
||||
let before = s.active().unwrap().wb.clone();
|
||||
let history = s.execute("history.list", json!({})).unwrap();
|
||||
for params in [json!({"dataTable": true}), json!({"dataTableKeys": true}), json!({"dataTable": "yes"})] {
|
||||
assert!(s.execute("chart.set", params).is_err());
|
||||
assert_eq!(s.active().unwrap().wb, before);
|
||||
assert_eq!(s.execute("history.list", json!({})).unwrap(), history);
|
||||
}
|
||||
assert!(s.execute("chart.addElement", json!({"element": "dataTable"})).is_err());
|
||||
s.execute("chart.set", json!({"type": "line", "dataTable": true})).unwrap();
|
||||
assert!(s.active().unwrap().wb.active().unwrap().charts[0].data_table.is_some());
|
||||
s.execute("chart.set", json!({"type": "scatter"})).unwrap();
|
||||
assert!(s.active().unwrap().wb.active().unwrap().charts[0].data_table.is_none());
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.active().unwrap().wb.active().unwrap().charts[0].data_table.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manage_rules_delete_then_move() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -260,6 +260,7 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
|
||||
series,
|
||||
legend: if matches!(kind, ChartKind::Pie | ChartKind::Doughnut) { LegendPos::Right } else { LegendPos::Bottom },
|
||||
data_labels: false,
|
||||
data_table: None,
|
||||
gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel),
|
||||
style: 1,
|
||||
x_title: None,
|
||||
|
||||
@@ -46,7 +46,7 @@ fn inspect(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
"freeze": sh.freeze,
|
||||
"merges": sh.merges.iter().map(|m| m.a1()).collect::<Vec<_>>(),
|
||||
"tables": sh.tables.iter().map(|t| json!({"name": t.name, "range": t.range.a1(), "style": t.style, "totals": t.totals_row, "columns": t.columns.iter().map(|c| c.name.clone()).collect::<Vec<_>>()})).collect::<Vec<_>>(),
|
||||
"charts": sh.charts.iter().map(|c| json!({"id": c.id, "kind": format!("{:?}", c.kind), "title": c.title, "at": c.anchor.cell.a1(), "series": c.series.len(), "trendlines": c.series.iter().flat_map(|s| s.trendlines.iter().map(|t| format!("{:?}", t.kind))).collect::<Vec<_>>()})).collect::<Vec<_>>(),
|
||||
"charts": sh.charts.iter().map(|c| json!({"id": c.id, "kind": format!("{:?}", c.kind), "title": c.title, "at": c.anchor.cell.a1(), "series": c.series.len(), "dataTable": c.data_table.as_ref().map(|t| json!({"showKeys": t.show_keys, "horizontalBorder": t.horizontal_border, "verticalBorder": t.vertical_border, "outline": t.outline})), "trendlines": c.series.iter().flat_map(|s| s.trendlines.iter().map(|t| format!("{:?}", t.kind))).collect::<Vec<_>>()})).collect::<Vec<_>>(),
|
||||
"images": sh.images.len(),
|
||||
"shapes": sh.shapes.len(),
|
||||
"conditionalFormats": sh.cond_formats.len(),
|
||||
|
||||
@@ -65,6 +65,7 @@ pub(super) fn insert_pivot_chart(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
series,
|
||||
legend: if matches!(kind, ChartKind::Pie | ChartKind::Doughnut) { LegendPos::Right } else { LegendPos::Bottom },
|
||||
data_labels: false,
|
||||
data_table: None,
|
||||
gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel),
|
||||
style: 1,
|
||||
x_title: None,
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
//! Printing: Page Setup, pagination and PDF output (File › Print, File › Export › PDF).
|
||||
//!
|
||||
//! Sheets are paginated like Excel's default "down, then over": the print area (or the used
|
||||
//! range plus drawing objects) is split into bands of rows and columns that fit the printable
|
||||
//! area at the page scale, honouring manual breaks, repeated title rows/columns, headings, Fit
|
||||
//! To and centring. Each page is drawn into a PDF with `gridcraft-pdf`: fills, gridlines,
|
||||
//! borders, formatted text (fonts mapped to the standard 14 PDF fonts), merged cells, wrapping,
|
||||
//! overflow into empty neighbours, conditional formats, table styles, charts, images, shapes and
|
||||
//! sparklines. Headers and footers support `&L`/`&C`/`&R` sections and the `&P &N &D &T &A &F &Z`
|
||||
//! codes.
|
||||
//! Sheets are paginated like Excel: the print area (or the used range plus drawing objects) is
|
||||
//! split into bands of rows and columns that fit the printable area at the page scale, honouring
|
||||
//! manual breaks, repeated title rows/columns, headings, Fit To and centring, and the pages follow
|
||||
//! the sheet's page order ("down, then over" by default, or "over, then down"). Each page is
|
||||
//! drawn into a PDF with `gridcraft-pdf`: fills, gridlines, borders, formatted text (fonts mapped
|
||||
//! to the standard 14 PDF fonts), merged cells, wrapping, overflow into empty neighbours,
|
||||
//! conditional formats, table styles, charts, images, shapes and sparklines. Headers and footers
|
||||
//! support `&L`/`&C`/`&R` sections and the `&P &N &D &T &A &F &Z` codes.
|
||||
|
||||
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value};
|
||||
#[cfg(test)]
|
||||
use gridcraft_model::{Alignment, Image, SheetViewMode};
|
||||
use gridcraft_model::{
|
||||
Anchor, BorderLine, BorderStyle, Borders, Color, HAlign, Orientation, PatternType, PrintSettings, ShapeKind, Sheet, Style, StyleId, StyleTable,
|
||||
Underline, VAlign, Visibility, Workbook,
|
||||
Anchor, BorderLine, BorderStyle, Borders, Color, HAlign, Orientation, PageOrder, PatternType, PrintSettings, ShapeKind, Sheet, Style, StyleId,
|
||||
StyleTable, Underline, VAlign, Visibility, Workbook,
|
||||
};
|
||||
use gridcraft_pdf::{Font, Page, PdfDoc, Point, Rect, Rgb};
|
||||
use serde_json::{Value as Json, json};
|
||||
@@ -69,7 +69,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Print Preview",
|
||||
["File"],
|
||||
None,
|
||||
"{page? (1-based), sheets?: active|all, range?, fitToPage?} → {pages, pageList: [{page, sheet, range, rows, columns, scale, paper, orientation, width, height}]}",
|
||||
"{page? (1-based), sheets?: active|all, range?, fitToPage?} → {pages, pageList: [{page, sheet, range, rows, columns, scale, paper, orientation, pageOrder, width, height}]} in print order",
|
||||
has_doc,
|
||||
print_preview
|
||||
),
|
||||
@@ -78,7 +78,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Page Setup…",
|
||||
["Page Layout", "Page Setup"],
|
||||
None,
|
||||
"{sheet?, orientation?: portrait|landscape, paper?: Letter|Legal|A4|A3|A5|A6|Tabloid|Executive|Ledger|B4|B5|B6|B4 (ISO)|B5 (ISO)|B6 (ISO)|Japanese Postcard|Japanese Double Postcard|Japanese Envelope #3|Japanese Envelope #4|Japanese Envelope Kaku #2|Japanese Envelope Kaku #3 (B4/B5/B6 are the JIS sizes), margins?: {left,right,top,bottom,header,footer} (inches; also accepted as top-level keys), scale?: 10..400, fitWidth?, fitHeight? (pages; 0 = automatic), fitToPage?: bool, centerH?, centerV?, gridlines?, headings?, header?: \"&L&A&RPage &P of &N\", footer?, printArea?: \"A1:F50\" (\"\" clears), titleRows?: \"1:2\", titleCols?: \"A:A\", rowBreaks?: [46, 91], colBreaks?: [\"K\"]} → the sheet's print settings",
|
||||
"{sheet?, orientation?: portrait|landscape, paper?: Letter|Legal|A4|A3|A5|A6|Tabloid|Executive|Ledger|B4|B5|B6|B4 (ISO)|B5 (ISO)|B6 (ISO)|Japanese Postcard|Japanese Double Postcard|Japanese Envelope #3|Japanese Envelope #4|Japanese Envelope Kaku #2|Japanese Envelope Kaku #3 (B4/B5/B6 are the JIS sizes), margins?: {left,right,top,bottom,header,footer} (inches; also accepted as top-level keys), scale?: 10..400, fitWidth?, fitHeight? (pages; 0 = automatic), fitToPage?: bool, centerH?, centerV?, gridlines?, headings?, header?: \"&L&A&RPage &P of &N\", footer?, printArea?: \"A1:F50\" (\"\" clears), titleRows?: \"1:2\", titleCols?: \"A:A\", rowBreaks?: [46, 91], colBreaks?: [\"K\"], pageOrder?: downThenOver|overThenDown} → the sheet's print settings",
|
||||
has_doc,
|
||||
page_setup
|
||||
),
|
||||
@@ -222,6 +222,7 @@ fn print_preview(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
"scale": (lay.scale * 1000.0).round() / 10.0,
|
||||
"paper": lay.settings.paper,
|
||||
"orientation": if lay.settings.orientation == Orientation::Landscape { "landscape" } else { "portrait" },
|
||||
"pageOrder": lay.settings.page_order.id(),
|
||||
"width": lay.page_w,
|
||||
"height": lay.page_h,
|
||||
}));
|
||||
@@ -244,6 +245,16 @@ fn page_setup(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Some("landscape") => Some(Orientation::Landscape),
|
||||
Some(o) => return Err(bad(CMD, format!("orientation must be portrait or landscape, not `{o}`"))),
|
||||
};
|
||||
let page_order = match p.get("pageOrder") {
|
||||
None => None,
|
||||
Some(v) => match v.as_str().and_then(PageOrder::parse) {
|
||||
Some(o) => Some(o),
|
||||
None => {
|
||||
let shown = v.as_str().map_or_else(|| v.to_string(), str::to_string);
|
||||
return Err(bad(CMD, format!("pageOrder must be downThenOver or overThenDown, not `{shown}`")));
|
||||
}
|
||||
},
|
||||
};
|
||||
let paper = match str_param(p, "paper") {
|
||||
None => None,
|
||||
Some(name) => {
|
||||
@@ -385,6 +396,9 @@ fn page_setup(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if let Some(b) = &col_breaks {
|
||||
pr.col_breaks = b.clone();
|
||||
}
|
||||
if let Some(o) = page_order {
|
||||
pr.page_order = o;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let pr = s.doc()?.wb.sheet(si).map(|sh| sh.print.clone()).unwrap_or_default();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::header_footer::HF_FIXED_SIDE_MARGIN;
|
||||
use super::{
|
||||
Anchor, CellRef, Json, MAX_COLS, MAX_PAGES, MAX_ROWS, Orientation, PX_TO_PT, PrintSettings, RangeRef, Result, Sheet, Visibility, Workbook, bad,
|
||||
bool_param, split_sheet, str_param,
|
||||
Anchor, CellRef, Json, MAX_COLS, MAX_PAGES, MAX_ROWS, Orientation, PX_TO_PT, PageOrder, PrintSettings, RangeRef, Result, Sheet, Visibility,
|
||||
Workbook, bad, bool_param, split_sheet, str_param,
|
||||
};
|
||||
|
||||
/// Cells in a page block above which only stored cells are visited (not every grid position).
|
||||
@@ -253,15 +253,7 @@ pub fn layout_sheet(wb: &Workbook, si: usize, opts: &PrintOptions, range_overrid
|
||||
let paginate = |lay: &mut SheetLayout, manual: bool| {
|
||||
lay.manual_breaks = manual;
|
||||
(lay.row_bands, lay.col_bands) = split_bands(sh, lay, MAX_PAGES);
|
||||
lay.pages.clear();
|
||||
'outer: for ci in 0..lay.col_bands.len() {
|
||||
for ri in 0..lay.row_bands.len() {
|
||||
if lay.pages.len() >= MAX_PAGES {
|
||||
break 'outer;
|
||||
}
|
||||
lay.pages.push((ri, ci));
|
||||
}
|
||||
}
|
||||
lay.pages = page_sequence(lay.row_bands.len(), lay.col_bands.len(), lay.settings.page_order, MAX_PAGES);
|
||||
};
|
||||
match fit {
|
||||
Some((fw, fh)) => {
|
||||
@@ -290,6 +282,25 @@ pub fn layout_sheet(wb: &Workbook, si: usize, opts: &PrintOptions, range_overrid
|
||||
Some(lay)
|
||||
}
|
||||
|
||||
/// The pages of a sheet `rows` pages tall and `cols` pages wide as (row band, column band), in
|
||||
/// print order, at most `cap` of them: down each column of pages, then over to the next (Excel's
|
||||
/// default), or over each row of pages, then down to the next.
|
||||
pub(crate) fn page_sequence(rows: usize, cols: usize, order: PageOrder, cap: usize) -> Vec<(usize, usize)> {
|
||||
let (outer, inner) = match order {
|
||||
PageOrder::DownThenOver => (cols, rows),
|
||||
PageOrder::OverThenDown => (rows, cols),
|
||||
};
|
||||
(0..outer)
|
||||
.flat_map(|o| {
|
||||
(0..inner).map(move |i| match order {
|
||||
PageOrder::DownThenOver => (i, o),
|
||||
PageOrder::OverThenDown => (o, i),
|
||||
})
|
||||
})
|
||||
.take(cap)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Inclusive-start/end index spans of the pages along one axis.
|
||||
pub(crate) type Bands = Vec<(u32, u32)>;
|
||||
|
||||
|
||||
@@ -16,6 +16,25 @@ fn contains(hay: &[u8], needle: &str) -> bool {
|
||||
hay.windows(needle.len()).any(|w| w == needle.as_bytes())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chart_data_table_prints_formatted_values_and_follows_recalculation() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "Sales"], ["Q1", 1234], ["Q2", 5678]]})).unwrap();
|
||||
s.execute("home.numberFormat", json!({"range": "B2:B3", "format": "#,##0.00"})).unwrap();
|
||||
s.execute("insert.chart", json!({"range": "A1:B3", "at": "D2", "type": "column"})).unwrap();
|
||||
let (off, pages) = pdf_of(&mut s, json!({"fitToPage": true}));
|
||||
assert_eq!(pages, 1);
|
||||
let count = |bytes: &[u8], text: &str| String::from_utf8_lossy(bytes).matches(text).count();
|
||||
s.execute("chart.set", json!({"dataTable": true})).unwrap();
|
||||
let (on, _) = pdf_of(&mut s, json!({"fitToPage": true}));
|
||||
assert_eq!(count(&on, "(1,234.00) Tj"), count(&off, "(1,234.00) Tj") + 1);
|
||||
assert_eq!(count(&on, "(5,678.00) Tj"), count(&off, "(5,678.00) Tj") + 1);
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "=1000+111"})).unwrap();
|
||||
let (updated, _) = pdf_of(&mut s, json!({"fitToPage": true}));
|
||||
assert!(!contains(&updated, "(1,234.00) Tj"));
|
||||
assert!(count(&updated, "(1,111.00) Tj") >= 2, "cell and chart data table both print the new value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_export_keeps_safe_links_on_their_printed_pages() {
|
||||
let mut s = s();
|
||||
@@ -148,6 +167,142 @@ fn wide_sheet_goes_down_then_over() {
|
||||
assert_eq!(pv["pageList"][2]["range"], "K1:O45");
|
||||
}
|
||||
|
||||
/// The ranges of the printed pages, in print order.
|
||||
fn page_ranges(s: &mut Session) -> Vec<String> {
|
||||
let pv = s.execute("file.printPreview", json!({})).unwrap();
|
||||
let list = pv["pageList"].as_array().unwrap();
|
||||
assert_eq!(pv["pages"], list.len());
|
||||
for (i, page) in list.iter().enumerate() {
|
||||
assert_eq!(page["page"], i + 1);
|
||||
assert_eq!(page["pageOrder"], s.doc().unwrap().wb.active().unwrap().print.page_order.id());
|
||||
}
|
||||
list.iter().map(|p| p["range"].as_str().unwrap().to_string()).collect()
|
||||
}
|
||||
|
||||
/// The content streams of an exported PDF's pages, in page order (pages without pictures are the
|
||||
/// file's only streams).
|
||||
fn pdf_pages(s: &mut Session) -> Vec<String> {
|
||||
let (pdf, pages) = pdf_of(s, json!({}));
|
||||
let text = String::from_utf8_lossy(&pdf);
|
||||
let streams: Vec<String> = text.split("endstream").filter(|part| part.contains("stream\n")).map(str::to_string).collect();
|
||||
assert_eq!(streams.len() as u64, pages);
|
||||
streams
|
||||
}
|
||||
|
||||
/// The page order measured in Excel: print area A1:D4 with a row break before row 3 and a column
|
||||
/// break before column C, so four pages two tall and two wide, each filled and labelled in its
|
||||
/// own colour, with `Page &P of &N` in the footer.
|
||||
fn two_by_two_pages() -> Session {
|
||||
let mut s = s();
|
||||
for (range, color, label) in
|
||||
[("A1:B2", "#FF0000", "red"), ("A3:B4", "#00FF00", "green"), ("C1:D2", "#0000FF", "blue"), ("C3:D4", "#FFFF00", "yellow")]
|
||||
{
|
||||
s.execute("home.fillColor", json!({"range": range, "color": color})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": &range[..2], "input": label})).unwrap();
|
||||
}
|
||||
s.execute("pageLayout.pageSetup", json!({"printArea": "A1:D4", "rowBreaks": [3], "colBreaks": ["C"], "footer": "Page &P of &N"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// The colour of each printed page, read from its fill and label; each page's footer must
|
||||
/// number it in print order.
|
||||
fn printed_colours(s: &mut Session) -> Vec<&'static str> {
|
||||
let pages = pdf_pages(s);
|
||||
let n = pages.len();
|
||||
pages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, page)| {
|
||||
assert!(page.contains(&format!("(Page {} of {n}) Tj", i + 1)), "page {}: {page}", i + 1);
|
||||
let found: Vec<&str> = [("red", "1 0 0 rg"), ("green", "0 1 0 rg"), ("blue", "0 0 1 rg"), ("yellow", "1 1 0 rg")]
|
||||
.into_iter()
|
||||
.filter(|(label, fill)| page.contains(&format!("({label}) Tj")) && page.contains(&format!("\n{fill}\n")))
|
||||
.map(|(label, _)| label)
|
||||
.collect();
|
||||
assert_eq!(found.len(), 1, "page {}: {found:?}", i + 1);
|
||||
found[0]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_order_sets_the_sequence_of_the_pages_as_measured_in_excel() {
|
||||
let mut s = two_by_two_pages();
|
||||
// Down, then over is the default: down the first column of pages, then the next.
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().print.page_order, PageOrder::DownThenOver);
|
||||
assert_eq!(s.execute("pageLayout.pageSetup", json!({})).unwrap()["page_order"], "downThenOver");
|
||||
let down = ["A1:B2", "A3:B4", "C1:D2", "C3:D4"];
|
||||
assert_eq!(page_ranges(&mut s), down);
|
||||
assert_eq!(printed_colours(&mut s), ["red", "green", "blue", "yellow"]);
|
||||
// Over, then down: across the first row of pages, then the next.
|
||||
let r = s.execute("pageLayout.pageSetup", json!({"pageOrder": "overThenDown"})).unwrap();
|
||||
assert_eq!(r["page_order"], "overThenDown");
|
||||
let over = ["A1:B2", "C1:D2", "A3:B4", "C3:D4"];
|
||||
assert_eq!(page_ranges(&mut s), over);
|
||||
assert_eq!(printed_colours(&mut s), ["red", "blue", "green", "yellow"]);
|
||||
assert_eq!(s.execute("file.printPreview", json!({"page": 2})).unwrap()["page"]["range"], "C1:D2");
|
||||
// One undo step.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(page_ranges(&mut s), down);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(page_ranges(&mut s), over);
|
||||
// Anything but the two orders is refused and changes nothing, even next to a good value.
|
||||
for bad in [json!("sideways"), json!("OverThenDown"), json!(""), json!(2), json!(null), json!(true), json!(["downThenOver"])] {
|
||||
let e = s.execute("pageLayout.pageSetup", json!({"pageOrder": bad, "orientation": "landscape"})).unwrap_err();
|
||||
assert!(e.to_string().contains("pageOrder"), "{bad}: {e}");
|
||||
}
|
||||
let pr = &s.doc().unwrap().wb.active().unwrap().print;
|
||||
assert_eq!((pr.page_order, pr.orientation), (PageOrder::OverThenDown, Orientation::Portrait));
|
||||
assert!(s.take_ui_requests().is_empty(), "a programmatic call never opens a dialog");
|
||||
// First-page and odd/even headers go by the printed sequence too.
|
||||
s.execute(
|
||||
"pageLayout.headerFooter",
|
||||
json!({"header": "&COdd &P", "evenHeader": "&CEven &P", "firstHeader": "&CCover", "differentFirst": true, "differentOddEven": true}),
|
||||
)
|
||||
.unwrap();
|
||||
let pages = pdf_pages(&mut s);
|
||||
for (page, (header, label)) in pages.iter().zip([("Cover", "red"), ("Even 2", "blue"), ("Odd 3", "green"), ("Even 4", "yellow")]) {
|
||||
assert!(page.contains(&format!("({header}) Tj")) && page.contains(&format!("({label}) Tj")), "{header}: {page}");
|
||||
}
|
||||
s.execute("pageLayout.pageSetup", json!({"pageOrder": "downThenOver"})).unwrap();
|
||||
assert_eq!(page_ranges(&mut s), down);
|
||||
let pages = pdf_pages(&mut s);
|
||||
for (page, (header, label)) in pages.iter().zip([("Cover", "red"), ("Even 2", "green"), ("Odd 3", "blue"), ("Even 4", "yellow")]) {
|
||||
assert!(page.contains(&format!("({header}) Tj")) && page.contains(&format!("({label}) Tj")), "{header}: {page}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_order_without_breaks_and_with_print_titles() {
|
||||
let mut s = s();
|
||||
// Every cell shows its own address: 60 rows and 15 columns are two pages tall and two wide.
|
||||
let rows: Vec<Vec<Json>> = (1..=60).map(|r| (0..15).map(|c| json!(format!("{}{r}", gridcraft_core::col_to_letters(c)))).collect()).collect();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
|
||||
for titles in [false, true] {
|
||||
if titles {
|
||||
s.execute("pageLayout.pageSetup", json!({"titleRows": "1:1", "titleCols": "A:A"})).unwrap();
|
||||
}
|
||||
s.execute("pageLayout.pageSetup", json!({"pageOrder": "downThenOver"})).unwrap();
|
||||
let down = page_ranges(&mut s);
|
||||
if !titles {
|
||||
assert_eq!(down, ["A1:J45", "A46:J60", "K1:O45", "K46:O60"]);
|
||||
}
|
||||
s.execute("pageLayout.pageSetup", json!({"pageOrder": "overThenDown"})).unwrap();
|
||||
let over = page_ranges(&mut s);
|
||||
assert_eq!(over, [down[0].clone(), down[2].clone(), down[1].clone(), down[3].clone()], "titles: {titles}");
|
||||
// Each printed page starts with its own top-left cell; the title row and column (A1 is in
|
||||
// both) repeat on every page in either order.
|
||||
for (order, ranges) in [("overThenDown", &over), ("downThenOver", &down)] {
|
||||
s.execute("pageLayout.pageSetup", json!({"pageOrder": order})).unwrap();
|
||||
for (i, (page, range)) in pdf_pages(&mut s).iter().zip(ranges.iter()).enumerate() {
|
||||
let top_left = range.split(':').next().unwrap();
|
||||
assert!(page.contains(&format!("({top_left}) Tj")), "{order} page {}: no {top_left}", i + 1);
|
||||
assert_eq!(page.contains("(A1) Tj"), titles || i == 0, "{order} page {}, titles: {titles}", i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_and_even_pages_print_their_own_headers() {
|
||||
let mut s = s();
|
||||
@@ -473,6 +628,11 @@ fn hostile_settings_never_panic_and_cap_pages() {
|
||||
assert_eq!(pv["pages"].as_u64().unwrap(), MAX_PAGES as u64);
|
||||
let r = s.execute("file.exportPdf", json!({})).unwrap();
|
||||
assert_eq!(r["pages"], MAX_PAGES);
|
||||
// Over, then down stops at the cap as well, along the first row of pages.
|
||||
s.execute("pageLayout.pageSetup", json!({"pageOrder": "overThenDown"})).unwrap();
|
||||
let pv = s.execute("file.printPreview", json!({})).unwrap();
|
||||
assert_eq!(pv["pages"].as_u64().unwrap(), MAX_PAGES as u64);
|
||||
assert_eq!(pv["pageList"][1]["rows"], pv["pageList"][0]["rows"], "the second page is beside the first");
|
||||
s.execute("pageLayout.pageSetup", json!({"fitWidth": 1, "fitHeight": 1, "titleRows": "1:1048576", "titleCols": "A:XFD", "headings": true}))
|
||||
.unwrap();
|
||||
let pv = s.execute("file.printPreview", json!({})).unwrap();
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! `(gap + j·(page_w + gap), gap + i·(page_h + gap))`, and its cells start at its top-left margin.
|
||||
|
||||
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef};
|
||||
use gridcraft_model::{LineIndex, Sheet, Workbook};
|
||||
use gridcraft_model::{LineIndex, PageOrder, Sheet, Workbook};
|
||||
|
||||
use crate::cmd::print::{self, PX_TO_PT, PrintOptions};
|
||||
|
||||
@@ -45,6 +45,8 @@ pub struct PageGrid {
|
||||
/// Bands holding printed data: `[first, last]` row bands and column bands.
|
||||
pub data_rows: (usize, usize),
|
||||
pub data_cols: (usize, usize),
|
||||
/// The sheet's page order, which numbers the printed pages.
|
||||
pub page_order: PageOrder,
|
||||
ri: LineIndex,
|
||||
ci: LineIndex,
|
||||
}
|
||||
@@ -96,6 +98,7 @@ impl PageGrid {
|
||||
col_bands,
|
||||
data_rows,
|
||||
data_cols,
|
||||
page_order: printed.settings.page_order,
|
||||
ri: LineIndex::new(&sh.rows, sh.default_row_height, MAX_ROWS),
|
||||
ci: LineIndex::new(&sh.cols, sh.default_col_width, MAX_COLS),
|
||||
})
|
||||
@@ -206,14 +209,14 @@ impl PageGrid {
|
||||
pub fn is_data_page(&self, i: usize, j: usize) -> bool {
|
||||
(self.data_rows.0..=self.data_rows.1).contains(&i) && (self.data_cols.0..=self.data_cols.1).contains(&j)
|
||||
}
|
||||
/// Printed page number of page `(i, j)` (down, then over) and the printed page count.
|
||||
/// Printed page number of page `(i, j)` (in the sheet's page order) and the printed page count.
|
||||
pub fn page_number(&self, i: usize, j: usize) -> Option<(usize, usize)> {
|
||||
if !self.is_data_page(i, j) {
|
||||
return None;
|
||||
}
|
||||
let rows = self.data_rows.1 - self.data_rows.0 + 1;
|
||||
let cols = self.data_cols.1 - self.data_cols.0 + 1;
|
||||
Some(((j - self.data_cols.0) * rows + (i - self.data_rows.0) + 1, rows * cols))
|
||||
Some((self.page_order.index(i - self.data_rows.0, j - self.data_cols.0, rows, cols) + 1, rows * cols))
|
||||
}
|
||||
|
||||
/// Page rows and columns overlapping the layout rectangle `[x0, x1] × [y0, y1]`.
|
||||
@@ -312,6 +315,21 @@ mod tests {
|
||||
assert!(g.is_data_page(0, 0) && !g.is_data_page(1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_numbers_follow_the_page_order() {
|
||||
// Two pages tall and two wide, as printed; the pages beyond are "Click to add data" pages.
|
||||
let mut wb = wb_with(60, 15);
|
||||
let numbers = |wb: &Workbook| {
|
||||
let g = PageGrid::build(wb, 0, 200, 60).unwrap();
|
||||
assert_eq!((g.data_rows, g.data_cols), ((0, 1), (0, 1)));
|
||||
assert_eq!((g.page_number(2, 0), g.page_number(0, 2)), (None, None));
|
||||
[(0, 0), (1, 0), (0, 1), (1, 1)].map(|(i, j)| g.page_number(i, j).unwrap())
|
||||
};
|
||||
assert_eq!(numbers(&wb), [(1, 4), (2, 4), (3, 4), (4, 4)]);
|
||||
wb.sheet_mut(0).unwrap().print.page_order = PageOrder::OverThenDown;
|
||||
assert_eq!(numbers(&wb), [(1, 4), (3, 4), (2, 4), (4, 4)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_zones_and_text_follow_scale_with_document_and_align_with_page_margins() {
|
||||
let mut wb = wb_with(10, 3);
|
||||
|
||||
Reference in New Issue
Block a user