mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-03 08:51:30 -06:00
Merge PR #3130
# Conflicts: # docs/file-format-parity.md # docs/target-app-parity.md # docs/ui-parity.md
This commit is contained in:
@@ -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)>;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user