# Conflicts:
#	docs/file-format-parity.md
#	docs/target-app-parity.md
#	docs/ui-parity.md
This commit is contained in:
Brandon Thomas
2026-11-01 23:08:37 -05:00
27 changed files with 579 additions and 47 deletions
+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)>;
+141
View File
@@ -167,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();
@@ -492,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();