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:
prxnvt
2026-11-02 01:30:43 -05:00
87 changed files with 4165 additions and 152 deletions
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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
),
+1
View File
@@ -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()));
+1
View File
@@ -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,
+82
View File
@@ -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> {
+136 -9
View File
@@ -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();
+1
View File
@@ -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,
+1 -1
View File
@@ -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(),
+1
View File
@@ -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,
+26 -12
View File
@@ -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();
+22 -11
View File
@@ -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)>;
+160
View File
@@ -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();
+21 -3
View File
@@ -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);