diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index cda52f31d..b3ce40d58 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -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 { "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 { "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 { "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 { 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 { 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(); diff --git a/crates/engine/src/cmd/print/layout.rs b/crates/engine/src/cmd/print/layout.rs index 83ff45183..0f484f875 100644 --- a/crates/engine/src/cmd/print/layout.rs +++ b/crates/engine/src/cmd/print/layout.rs @@ -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)>; diff --git a/crates/engine/src/cmd/print/tests.rs b/crates/engine/src/cmd/print/tests.rs index 826e5c99a..f3f1c1f22 100644 --- a/crates/engine/src/cmd/print/tests.rs +++ b/crates/engine/src/cmd/print/tests.rs @@ -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 { + 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 { + let (pdf, pages) = pdf_of(s, json!({})); + let text = String::from_utf8_lossy(&pdf); + let streams: Vec = 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> = (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(); diff --git a/crates/engine/src/paper.rs b/crates/engine/src/paper.rs index 0800f4fc7..99981fc8c 100644 --- a/crates/engine/src/paper.rs +++ b/crates/engine/src/paper.rs @@ -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); diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index fc27ad99e..4626c0c69 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -10,7 +10,7 @@ mod pivot; mod print; pub use pivot::{PivotCalcField, PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotLayout, PivotShowAs, PivotSort, PivotTable, PivotValue}; -pub use print::{EditRange, HfPage, LineInfo, Orientation, PrintSettings, SheetProtection, password_hash}; +pub use print::{EditRange, HfPage, LineInfo, Orientation, PageOrder, PrintSettings, SheetProtection, password_hash}; // ---------------------------------------------------------------- tables diff --git a/crates/model/src/features/print.rs b/crates/model/src/features/print.rs index cbcaf3e55..694c2ac6d 100644 --- a/crates/model/src/features/print.rs +++ b/crates/model/src/features/print.rs @@ -49,12 +49,52 @@ pub struct PrintSettings { pub hf_scale_with_doc: bool, #[serde(default = "yes")] pub hf_align_with_margins: bool, + /// Page Setup › Sheet › Page order: the sequence pages are numbered and printed in when the + /// sheet is more than one page wide and tall. + #[serde(default)] + pub page_order: PageOrder, } fn yes() -> bool { true } +/// The sequence of a sheet's pages (XLSX `pageSetup pageOrder`, ECMA-376 `ST_PageOrder`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PageOrder { + /// Down the first column of pages, then down the next one (Excel's default). + #[default] + DownThenOver, + /// Across the first row of pages, then across the next one. + OverThenDown, +} + +impl PageOrder { + /// The id `pageLayout.pageSetup` takes and XLSX writes. + pub fn id(self) -> &'static str { + match self { + PageOrder::DownThenOver => "downThenOver", + PageOrder::OverThenDown => "overThenDown", + } + } + pub fn parse(id: &str) -> Option { + match id { + "downThenOver" => Some(PageOrder::DownThenOver), + "overThenDown" => Some(PageOrder::OverThenDown), + _ => None, + } + } + /// The 0-based place in the printed sequence of the page in page row `row` and page column + /// `col`, on a sheet `rows` pages tall and `cols` pages wide. + pub fn index(self, row: usize, col: usize, rows: usize, cols: usize) -> usize { + match self { + PageOrder::DownThenOver => col.saturating_mul(rows).saturating_add(row), + PageOrder::OverThenDown => row.saturating_mul(cols).saturating_add(col), + } + } +} + /// Which header/footer pair a printed page uses. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum HfPage { @@ -146,6 +186,7 @@ impl Default for PrintSettings { even_footer: String::new(), hf_scale_with_doc: true, hf_align_with_margins: true, + page_order: PageOrder::DownThenOver, } } } @@ -215,3 +256,29 @@ pub struct LineInfo { #[serde(default)] pub custom: bool, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn page_order_numbers_pages_and_old_settings_load_down_then_over() { + // Two pages tall, three wide: (row, col) → place in the printed sequence. + let place = |order: PageOrder| [(0, 0), (1, 0), (0, 1), (1, 1), (0, 2), (1, 2)].map(|(r, c)| order.index(r, c, 2, 3)); + assert_eq!(place(PageOrder::DownThenOver), [0, 1, 2, 3, 4, 5]); + assert_eq!(place(PageOrder::OverThenDown), [0, 3, 1, 4, 2, 5]); + assert_eq!(PageOrder::OverThenDown.index(usize::MAX, usize::MAX, usize::MAX, usize::MAX), usize::MAX); + for order in [PageOrder::DownThenOver, PageOrder::OverThenDown] { + assert_eq!(PageOrder::parse(order.id()), Some(order)); + } + assert_eq!(PageOrder::parse("OverThenDown"), None); + // Saved under the same id the command takes; settings saved before the field existed load + // with Excel's default. + let mut p = PrintSettings { page_order: PageOrder::OverThenDown, ..Default::default() }; + let mut saved = serde_json::to_value(&p).unwrap(); + assert_eq!(saved["page_order"], "overThenDown"); + assert!(saved.as_object_mut().unwrap().remove("page_order").is_some()); + p = serde_json::from_value(saved).unwrap(); + assert_eq!(p, PrintSettings::default()); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9b04f1384..d791b2851 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -551,14 +551,20 @@ impl Dialog { }, ), "pageSetup" => { - // Opens on the active sheet's saved margins, so OK without edits rewrites them unchanged. - let m = app.session.active().and_then(|d| d.wb.active()).map(|s| s.print.margins).unwrap_or([0.7, 0.7, 0.75, 0.75, 0.3, 0.3]); + // Opens on the active sheet's saved margins and page order, so OK without edits + // rewrites them unchanged. One `pageLayout.pageSetup` sets them all: one undo step. + let (margins, order) = app + .session + .active() + .and_then(|d| d.wb.active()) + .map(|s| (s.print.margins, s.print.page_order)) + .unwrap_or(([0.7, 0.7, 0.75, 0.75, 0.3, 0.3], Default::default())); // Round off f32 noise so the form shows 0.1, not 0.10000000149. - let [left, right, top, bottom, header, footer] = m.map(|v| (f64::from(v) * 1e4).round() / 1e4); + let [left, right, top, bottom, header, footer] = margins.map(|v| (f64::from(v) * 1e4).round() / 1e4); Dialog::form( "pageSetup", "Page Setup", - "pageLayout.margins", + "pageLayout.pageSetup", vec![ Number { key: "left", label: "Left margin (in):" }, Number { key: "right", label: "Right margin (in):" }, @@ -566,8 +572,13 @@ impl Dialog { Number { key: "bottom", label: "Bottom margin (in):" }, Number { key: "header", label: "Header (in):" }, Number { key: "footer", label: "Footer (in):" }, + Choice { + key: "pageOrder", + label: "Page order", + options: vec![("downThenOver", "Down, then over"), ("overThenDown", "Over, then down")], + }, ], - json!({"left": left, "right": right, "top": top, "bottom": bottom, "header": header, "footer": footer}), + json!({"left": left, "right": right, "top": top, "bottom": bottom, "header": header, "footer": footer, "pageOrder": order.id()}), ) } "textToColumns" => Dialog::form( diff --git a/crates/ui-egui/src/dialogs/show.rs b/crates/ui-egui/src/dialogs/show.rs index 008b23951..b42e96abe 100644 --- a/crates/ui-egui/src/dialogs/show.rs +++ b/crates/ui-egui/src/dialogs/show.rs @@ -12,6 +12,7 @@ use super::paste::{paste_special, paste_special_shortcuts}; use super::spelling::spelling; use super::zoom::zoom; use super::{Dialog, Field, Json, Key, Map, RefPick, SheetApp, json, vec2}; +use gridcraft_engine::model::PageOrder; /// Builds the command params from form values for dialogs that need translation. /// The parameters OK sends to the dialog's command: its field values, reshaped per dialog. @@ -105,6 +106,15 @@ pub(crate) fn build_params(d: &Dialog) -> Json { } } } + "pageSetup" => { + // A margin dragged below zero is zero, as the Margins command makes it (Page Setup + // refuses negative margins from other callers). + for key in ["left", "right", "top", "bottom", "header", "footer"] { + if let Some(inches) = d.values.get(key).and_then(Json::as_f64) { + v[key] = json!(inches.max(0.0)); + } + } + } _ => {} } v @@ -579,10 +589,22 @@ pub(super) fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) { .response .labelled_by(field_label.id); } else { - for (val, text) in options { - if ui.radio(cur == val, lang.tr(text)).clicked() { - d.set_value(key, json!(val)); + let radios = |ui: &mut egui::Ui, d: &mut Dialog| { + for (val, text) in &options { + if ui.radio(cur == *val, lang.tr(text)).clicked() { + d.set_value(key, json!(val)); + } } + }; + if key == "pageOrder" { + // Page Setup pictures the order beside its choices. + ui.horizontal(|ui| { + ui.vertical(|ui| radios(ui, d)); + ui.add_space(24.0); + page_order_preview(ui, PageOrder::parse(&cur).unwrap_or_default()); + }); + } else { + radios(ui, d); } } }); @@ -604,3 +626,30 @@ pub(super) fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) { } }); } + +/// Page Setup's picture of a page order: four pages, two tall and two wide, numbered in the +/// order they print, with a line through them from the first to the last. +fn page_order_preview(ui: &mut egui::Ui, order: PageOrder) { + let t = crate::theme::Tokens::get(ui.ctx()); + let (page, gap) = (vec2(22.0, 28.0), 8.0); + let (rect, _) = ui.allocate_exact_size(vec2(page.x * 2.0 + gap, page.y * 2.0 + gap), egui::Sense::hover()); + let p = ui.painter(); + let mut path = [egui::Pos2::ZERO; 4]; + for (row, col) in [(0, 0), (1, 0), (0, 1), (1, 1)] { + let r = egui::Rect::from_min_size(rect.min + vec2(col as f32 * (page.x + gap), row as f32 * (page.y + gap)), page); + p.rect_filled(r, 1.0, t.paper); + p.rect_stroke(r, 1.0, egui::Stroke::new(1.0, t.text_dim), egui::StrokeKind::Inside); + let n = order.index(row, col, 2, 2); + p.text(r.left_top() + vec2(4.0, 2.0), egui::Align2::LEFT_TOP, (n + 1).to_string(), crate::theme::ui_font(9.0), t.text); + if let Some(at) = path.get_mut(n) { + *at = r.center() + vec2(2.0, 3.0); + } + } + let stroke = egui::Stroke::new(1.5, t.accent); + p.add(egui::Shape::line(path.to_vec(), stroke)); + // An arrowhead on the last page. + let (from, to) = (path[2], path[3]); + let d = (to - from).normalized(); + let n = vec2(-d.y, d.x); + p.add(egui::Shape::convex_polygon(vec![to, to - d * 6.0 + n * 3.5, to - d * 6.0 - n * 3.5], t.accent, egui::Stroke::NONE)); +} diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 420214ffb..7dfb7d329 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -137,6 +137,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Portrait" => "Hochformat", "Landscape" => "Querformat", "Custom Margins…" => "Benutzerdefinierte Seitenränder…", + "Page Setup" => "Seite einrichten", + "Left margin (in):" => "Linker Seitenrand (Zoll):", + "Right margin (in):" => "Rechter Seitenrand (Zoll):", + "Top margin (in):" => "Oberer Seitenrand (Zoll):", + "Bottom margin (in):" => "Unterer Seitenrand (Zoll):", + "Header (in):" => "Kopfzeile (Zoll):", + "Footer (in):" => "Fußzeile (Zoll):", + "Page order" => "Seitenreihenfolge", + "Down, then over" => "Seiten nach unten, dann nach rechts", + "Over, then down" => "Seiten nach rechts, dann nach unten", "Insert\nFunction" => "Funktion\neinfügen", "Name\nManager" => "Namens-\nmanager", "Define Name" => "Namen definieren", diff --git a/crates/ui-egui/src/i18n/el.rs b/crates/ui-egui/src/i18n/el.rs index cbc043ece..96ffc7b1d 100644 --- a/crates/ui-egui/src/i18n/el.rs +++ b/crates/ui-egui/src/i18n/el.rs @@ -675,6 +675,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Bottom margin (in):" => "Κάτω περιθώριο (ίντσες):", "Header (in):" => "Κεφαλίδα (ίντσες):", "Footer (in):" => "Υποσέλιδο (ίντσες):", + "Page order" => "Σειρά σελίδων", + "Down, then over" => "Προς τα κάτω και μετά δεξιά", + "Over, then down" => "Δεξιά και μετά προς τα κάτω", "Tab" => "Στηλοθέτης", "Semicolon" => "Ερωτηματικό (;)", "Comma" => "Κόμμα", diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 599b9c2a5..dfd8ed6bb 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -556,6 +556,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Bottom margin (in):" => "Margine inferiore (poll.):", "Header (in):" => "Intestazione (poll.):", "Footer (in):" => "Piè di pagina (poll.):", + "Page order" => "Ordine pagine", + "Down, then over" => "Prima in basso, poi a destra", + "Over, then down" => "Prima a destra, poi in basso", "Tab" => "Tabulazione", "Semicolon" => "Punto e virgola", "Comma" => "Virgola", diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 65015dfe0..ad5f4839a 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -578,6 +578,9 @@ pub(super) const DIALOGS: &[(&str, &str)] = &[ ("Bottom margin (in):", "下余白 (インチ):"), ("Header (in):", "ヘッダー (インチ):"), ("Footer (in):", "フッター (インチ):"), + ("Page order", "ページの順序"), + ("Down, then over", "上から下へ、次に右へ"), + ("Over, then down", "左から右へ、次に下へ"), ("Tab", "タブ"), ("Semicolon", "セミコロン"), ("Comma", "コンマ"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index ae4639760..d798d3cd1 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -550,6 +550,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Bottom margin (in):" => "Dolny margines (cale):", "Header (in):" => "Nagłówek (cale):", "Footer (in):" => "Stopka (cale):", + "Page order" => "Kolejność stron", + "Down, then over" => "W dół, potem w poprzek", + "Over, then down" => "W poprzek, potem w dół", "Tab" => "Tabulator", "Semicolon" => "Średnik", "Comma" => "Przecinek", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 32b38a953..d14428ddb 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -572,6 +572,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Bottom margin (in):" => "Margem inferior (pol.):", "Header (in):" => "Cabeçalho (pol.):", "Footer (in):" => "Rodapé (pol.):", + "Page order" => "Ordem das páginas", + "Down, then over" => "Para baixo, depois para a direita", + "Over, then down" => "Para a direita, depois para baixo", "Tab" => "Tabulação", "Semicolon" => "Ponto e vírgula", "Comma" => "Vírgula", diff --git a/crates/ui-egui/src/i18n/ro.rs b/crates/ui-egui/src/i18n/ro.rs index f92555df2..382a67d5b 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -556,6 +556,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Bottom margin (in):" => "Margine jos (in):", "Header (in):" => "Antet (in):", "Footer (in):" => "Subsol (in):", + "Page order" => "Ordinea paginilor", + "Down, then over" => "În jos, apoi la dreapta", + "Over, then down" => "La dreapta, apoi în jos", "Tab" => "Tab", "Semicolon" => "Punct și virgulă", "Comma" => "Virgulă", diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 699a22288..e3f055c11 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -554,6 +554,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Bottom margin (in):" => "Bottom margin (in):", "Header (in):" => "Header (in):", "Footer (in):" => "Footer (in):", + "Page order" => "Порядок сторінок", + "Down, then over" => "Вниз, потім праворуч", + "Over, then down" => "Праворуч, потім униз", "Tab" => "Tab", "Semicolon" => "Semicolon", "Comma" => "Comma", diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 233383acd..725bc5acc 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -75,6 +75,7 @@ mod open_encrypted; mod outline; mod page_layout_view; mod page_setup_margins; +mod page_setup_page_order; mod paste_names; mod paste_special_dialog; mod pivot_pane; diff --git a/crates/ui-egui/tests/it/page_setup_margins.rs b/crates/ui-egui/tests/it/page_setup_margins.rs index 79b36f897..bf3052b5a 100644 --- a/crates/ui-egui/tests/it/page_setup_margins.rs +++ b/crates/ui-egui/tests/it/page_setup_margins.rs @@ -25,9 +25,9 @@ fn page_setup_ok_without_edits_keeps_custom_margins() { let mut app = app_with_custom_margins(); app.open_dialog("pageSetup", params); let d = app.dialog.take().unwrap(); - let shown = json!({"left": 2.0, "right": 2.5, "top": 3.0, "bottom": 0.25, "header": 0.1, "footer": 0.2}); + let shown = json!({"left": 2.0, "right": 2.5, "top": 3.0, "bottom": 0.25, "header": 0.1, "footer": 0.2, "pageOrder": "downThenOver"}); assert_eq!(Value::Object(d.values.clone()), shown); - app.run("pageLayout.margins", Value::Object(d.values)).unwrap(); + app.run(d.command.unwrap(), Value::Object(d.values)).unwrap(); assert_eq!(margins(&app), [2.0, 2.5, 3.0, 0.25, 0.1, 0.2]); } } @@ -38,6 +38,6 @@ fn page_setup_one_edited_field_keeps_the_other_margins() { app.open_dialog("pageSetup", json!({})); let mut d = app.dialog.take().unwrap(); d.values.insert("left".into(), json!(1.5)); - app.run("pageLayout.margins", Value::Object(d.values)).unwrap(); + app.run(d.command.unwrap(), Value::Object(d.values)).unwrap(); assert_eq!(margins(&app), [1.5, 2.5, 3.0, 0.25, 0.1, 0.2]); } diff --git a/crates/ui-egui/tests/it/page_setup_page_order.rs b/crates/ui-egui/tests/it/page_setup_page_order.rs new file mode 100644 index 000000000..2d797d4c9 --- /dev/null +++ b/crates/ui-egui/tests/it/page_setup_page_order.rs @@ -0,0 +1,111 @@ +//! Page Setup › Page order: the dialog shows the sheet's order, and OK sets the chosen one with the +//! margins in one undo step, so printing goes down, then over or over, then down. + +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::model::PageOrder; +use gridcraft_ui_egui::SheetApp; +use gridcraft_ui_egui::control::{Outcome, handle}; +use gridcraft_ui_egui::i18n::Language; +use serde_json::{Value as Json, json}; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +/// Four pages, two tall and two wide: print area A1:D4 with breaks before row 3 and column C. +fn harness() -> Harness { + let mut h = crate::support::blank_harness(4); + let app = h.state_mut(); + app.run("range.setValues", json!({"range": "A1", "values": [["red", null, "blue"], [], ["green", null, "yellow"]]})).unwrap(); + app.run("pageLayout.pageSetup", json!({"printArea": "A1:D4", "rowBreaks": [3], "colBreaks": ["C"], "left": 1.25})).unwrap(); + h.run_steps(2); + h +} + +fn order(h: &Harness) -> (PageOrder, f32) { + let print = &h.state().session.active().unwrap().wb.active().unwrap().print; + (print.page_order, print.margins[0]) +} + +/// The printed pages' ranges, in print order. +fn pages(h: &mut Harness) -> Vec { + let pv = h.state_mut().run("file.printPreview", json!({})).unwrap(); + pv["pageList"].as_array().unwrap().iter().map(|p| p["range"].as_str().unwrap().to_string()).collect() +} + +fn control(h: &mut Harness, method: &str, params: Json) -> Json { + let ctx = h.ctx.clone(); + match handle(h.state_mut(), &ctx, method, ¶ms) { + Outcome::Done(v) => v, + Outcome::Screenshot { .. } => json!(null), + } +} + +#[test] +fn page_setup_sets_the_page_order() { + let mut h = harness(); + assert_eq!(pages(&mut h), ["A1:B2", "A3:B4", "C1:D2", "C3:D4"]); + // Page Layout › Print Titles opens Page Setup on its Sheet tab. + h.state_mut().open_dialog("pageSetup", json!({"tab": "Sheet"})); + h.run_steps(3); + assert!(h.query_by_label("Page order").is_some()); + assert_eq!(h.state().dialog.as_ref().unwrap().values["pageOrder"], "downThenOver"); + h.get_by_label("Over, then down").click(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().unwrap().values["pageOrder"], "overThenDown"); + h.get_by_label_contains("OK").click(); + h.run_steps(2); + assert!(h.state().dialog.is_none(), "OK closes the dialog"); + assert_eq!(order(&h), (PageOrder::OverThenDown, 1.25), "the order is set and the margins kept"); + assert_eq!(pages(&mut h), ["A1:B2", "C1:D2", "A3:B4", "C3:D4"]); + // Opened again, it shows the sheet's order. + h.state_mut().open_dialog("pageSetup", json!({})); + assert_eq!(h.state().dialog.as_ref().unwrap().values["pageOrder"], "overThenDown"); + h.state_mut().dialog = None; + // One undo step takes it back. + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(order(&h), (PageOrder::DownThenOver, 1.25)); + assert_eq!(pages(&mut h), ["A1:B2", "A3:B4", "C1:D2", "C3:D4"]); +} + +#[test] +fn confirming_page_setup_sets_the_order_and_keeps_margins_at_zero_or_more() { + let mut h = harness(); + control(&mut h, "ui.dialog", json!({"name": "pageSetup"})); + control(&mut h, "ui.dialog.set", json!({"field": "pageOrder", "value": "overThenDown"})); + // A margin dragged below zero is zero, as with the Margins command. + control(&mut h, "ui.dialog.set", json!({"field": "left", "value": -0.5})); + let r = control(&mut h, "ui.dialog.confirm", json!({})); + assert_eq!(r["ok"], json!(true), "{r}"); + assert_eq!(order(&h), (PageOrder::OverThenDown, 0.0)); + // A bad order is refused and the dialog stays open. + control(&mut h, "ui.dialog", json!({"name": "pageSetup"})); + control(&mut h, "ui.dialog.set", json!({"field": "pageOrder", "value": "sideways"})); + let r = control(&mut h, "ui.dialog.confirm", json!({})); + assert_eq!(r["ok"], json!(false), "{r}"); + assert!(h.state().dialog.is_some()); + assert_eq!(order(&h).0, PageOrder::OverThenDown); +} + +#[test] +fn page_order_is_translated_where_page_setup_is() { + let mut h = harness(); + h.state_mut().run("app.language.set", json!({"language": "de"})).unwrap(); + h.state_mut().open_dialog("pageSetup", json!({"tab": "Sheet"})); + h.run_steps(3); + for (english, german) in [ + ("Page order", "Seitenreihenfolge"), + ("Down, then over", "Seiten nach unten, dann nach rechts"), + ("Over, then down", "Seiten nach rechts, dann nach unten"), + ] { + assert!(h.query_by_label(german).is_some(), "no {german}"); + assert!(h.query_by_label(english).is_none(), "{english} is untranslated"); + } + // Every catalog with the dialog's margin labels has the page order too. + for language in Language::ALL { + if language.tr("Left margin (in):") == "Left margin (in):" { + continue; + } + for label in ["Page order", "Down, then over", "Over, then down"] { + assert_ne!(language.tr(label), label, "{language:?}: {label}"); + } + } +} diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 8dcb99d28..27913757a 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -8,8 +8,8 @@ use gridcraft_core::{CellError, CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRe use gridcraft_formula::Expr; use gridcraft_model::{ AutoFilter, Cell, CfOperator, CfRule, CfValueKind, Comment, CondFormat, EditRange, ErrorStyle, FilterCriterion, Formula, Hyperlink, LineInfo, - NoteBox, NoteCorner, Orientation, PasswordHash, Sheet, SheetProtection, Sparkline, SparklineKind, Table, TableColumn, TotalsFn, Validation, - ValidationKind, XmlColumnPr, + NoteBox, NoteCorner, Orientation, PageOrder, PasswordHash, Sheet, SheetProtection, Sparkline, SparklineKind, Table, TableColumn, TotalsFn, + Validation, ValidationKind, XmlColumnPr, }; use crate::IoError; @@ -191,6 +191,8 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result(s: &mut String, sheet: &'a Sheet, rels: &mut Rels, o if let Some((w, h)) = p.fit_to { let _ = write!(s, " fitToWidth=\"{w}\" fitToHeight=\"{h}\""); } + // Only "over, then down" is written, as Excel does: `downThenOver` is the schema default. + if p.page_order != PageOrder::DownThenOver { + let _ = write!(s, " pageOrder=\"{}\"", p.page_order.id()); + } let _ = write!(s, " orientation=\"{}\"", if p.orientation == Orientation::Landscape { "landscape" } else { "portrait" }); if let Some(id) = kept.page_setup_id() { let _ = write!(s, " r:id=\"{id}\""); diff --git a/crates/xlsx/src/tests.rs b/crates/xlsx/src/tests.rs index c511254f0..c03ff01bc 100644 --- a/crates/xlsx/src/tests.rs +++ b/crates/xlsx/src/tests.rs @@ -18,6 +18,7 @@ mod malformed; mod multilevel_category_roundtrip; mod note_box; mod ods_tests; +mod page_order; mod passthrough_tests; mod phonetic_tests; mod pivot_tests; diff --git a/crates/xlsx/src/tests/page_order.rs b/crates/xlsx/src/tests/page_order.rs new file mode 100644 index 000000000..f7aa40eb1 --- /dev/null +++ b/crates/xlsx/src/tests/page_order.rs @@ -0,0 +1,63 @@ +//! Page Setup › Sheet › Page order: `` is read and written back. As in +//! Excel's files, only "over, then down" is written; "down, then over" is the schema default. + +use gridcraft_model::{PageOrder, Workbook}; + +use super::minimal; +use crate::{read_xlsx, write_xlsx}; + +/// The first sheet's XML as written. +fn sheet_xml(bytes: &[u8]) -> String { + String::from_utf8(crate::package::Package::open(bytes).unwrap().read("xl/worksheets/sheet1.xml").unwrap().unwrap()).unwrap() +} + +/// The `` element of a sheet's XML. +fn page_setup(xml: &str) -> &str { + let start = xml.find("").unwrap() + 2; + &xml[start..start + len] +} + +#[test] +fn over_then_down_is_read_and_written_back() { + // The attributes Excel writes for a sheet set to Over, then down. + let body = r#""#; + let (wb, _) = read_xlsx(&minimal(body, &[], "", "")).unwrap(); + assert_eq!(wb.sheet(0).unwrap().print.page_order, PageOrder::OverThenDown); + let bytes = write_xlsx(&wb).unwrap(); + let xml = sheet_xml(&bytes); + let ps = page_setup(&xml); + assert_eq!(ps, r#""#); + let (back, _) = read_xlsx(&bytes).unwrap(); + assert_eq!(back.sheet(0).unwrap().print.page_order, PageOrder::OverThenDown); + // CT_PageSetup's attribute order: Fit To before the page order, the page order before the + // orientation. + let mut wb = back; + wb.sheet_mut(0).unwrap().print.fit_to = Some((1, 2)); + let xml = sheet_xml(&write_xlsx(&wb).unwrap()); + assert!(page_setup(&xml).contains(r#"fitToHeight="2" pageOrder="overThenDown" orientation="portrait""#), "{xml}"); +} + +#[test] +fn down_then_over_writes_no_page_order() { + // Written out, absent, unknown (read as the default) and a sheet without page setup at all. + for body in [ + r#""#, + r#""#, + r#""#, + "", + ] { + let (wb, _) = read_xlsx(&minimal(body, &[], "", "")).unwrap(); + assert_eq!(wb.sheet(0).unwrap().print.page_order, PageOrder::DownThenOver, "{body}"); + let xml = sheet_xml(&write_xlsx(&wb).unwrap()); + assert!(!xml.contains("pageOrder"), "{body}: {xml}"); + assert!(xml.matches(" Workbook { even_footer: "&R&A".into(), hf_scale_with_doc: false, hf_align_with_margins: true, + page_order: PageOrder::OverThenDown, }; s.sparklines.push(Sparkline { cell: at("J2"), source: "B2:B3".into(), kind: SparklineKind::WinLoss, color: Color::Rgb(0x112233), markers: true }); s.tables.push(Table { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 859b7a241..7e90fce3f 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-01 · **Change:** minor (chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-01 · **Change:** minor (XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -118,7 +118,7 @@ past the parser's limit, and a wrapper inside the text. **Saving writes the real |---|---|---|---| | PDF | Save As / Export | yes (own PDF 1.7 writer; CLI `convert` output) | Printed cell hyperlinks export as clickable HTTP(S)/mailto links or destinations within printed pages (#954); unsafe schemes and destinations outside the print range are omitted. Only the 14 standard PDF fonts, no embedding: **non-Latin text (CJK, Arabic, Devanagari, Thai) does not export**; web build exports without a file prompt (#141) | | XPS | Windows | no | Low priority | -| Print | yes | yes (via PDF, system dialog on desktop; PR #143 for web) | | +| Print | yes | yes (via PDF, system dialog on desktop; PR #143 for web) | Pages print in the sheet's page order: down, then over (the default) or over, then down | | Images of ranges/charts (Copy as Picture) | yes | chart raster in `crates/chart` | No range-to-image command | ## Import sources (Get & Transform) @@ -146,6 +146,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| | 2026-11-01 | minor | Chart data tables preserve showKeys, showHorzBorder, showVertBorder and showOutline through native c:dTable XLSX import/export; synthetic tests cover all 16 flag combinations and boolean defaults. Excel-side verification remains outstanding. | +| 2026-11-01 | minor | XLSX reads and writes Page Setup's page order: `` for Over, then down, and no attribute for Down, then over (the default), as Excel writes them; PDF export and printing number and order the pages by it | | 2026-11-01 | trivial | XLSX selects the first visible workbook view as one unit and bounds imported/exported tab ratios; isolated attributes and sheet-edit round trips covered, with firstSheet left to #2264 (#3057) | | 2026-10-31 | minor | CSV and text open detects each column's date order: a value that reads only day first (`31/05/2026`) makes the column day first, a value that reads only month first (`12/31/2026`) makes it month first, an all-ambiguous column keeps month first with a warning, and a column with both kinds stays text with a warning (#2765) | | 2026-10-31 | minor | XLSX open reads Google Sheets' `IFERROR(__xludf.DUMMYFUNCTION("…"),cached)` export wrapper as the formula inside when GridCraft calculates every function in it (plain, shared and array formulas, whole or nested; Google-only functions and unparseable, over-long or over-deep inner text keep the wrapper and the cached value) and keeps the cached value; saving writes the real formula with Excel's `_xlfn.` prefixes (#2871) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3b4ce035b..cb9e1641f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: GridCraft vs Microsoft Excel -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** minor (charts: data tables with XLSX round-trip and print/PDF; no score change; earlier: what-if and analysis: Solver Options; the Insert Function dialog is named as in Excel; no score change; earlier: Solver Load/Save Model and Reset All; automation: macro interface; catalog count 276 / 292, as `cargo xtask parity` measures; automation: typed macros in the engine; several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** minor (page layout and print: Page Setup's page order; no score change; earlier: charts: data tables with XLSX round-trip and print/PDF; no score change; earlier: what-if and analysis: Solver Options; the Insert Function dialog is named as in Excel; no score change; earlier: Solver Load/Save Model and Reset All; automation: macro interface; catalog count 276 / 292, as `cargo xtask parity` measures; automation: typed macros in the engine; several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365) This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it, [`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail: @@ -89,6 +89,10 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 | Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, Page Break Preview, headers/footers, PDF export and printing. Paper sizes match Japanese Excel: B4/B5/B6 are the JIS sizes, ISO B4/B5/B6 stay reachable by name, and Japanese Postcard (はがき), Japanese Double Postcard (往復はがき), the chou/kaku envelopes and A6 are available. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) | | Charts and sparklines | 8 | ~50% | ~30% | 40–60 | 25 chart kinds, all 2-D. The chart model holds title, legend position, a data-labels flag, gridlines flag, axis titles and chart data tables (legend keys, borders, XLSX round-trip and print/PDF); no axis scaling, error bars, per-point formatting, chart sheets, 3-D, surface, map, Pareto. Kinds survive XLSX round trip (#158, #161). [chart-parity.md](chart-parity.md) | | Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, Page Break Preview, headers/footers, PDF export and printing. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) | +| Charts and sparklines | 8 | ~50% | ~30% | 40–60 | 25 chart kinds, all 2-D. The chart model holds title, legend position, a data-labels flag, gridlines flag and axis titles; no axis scaling, trendlines, error bars, per-point formatting, chart sheets, 3-D, surface, map, Pareto. Several kinds don't survive XLSX round trip (open PRs #158, #161). [chart-parity.md](chart-parity.md) | +| Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, page order, Page Break Preview, headers/footers, PDF export and printing. Paper sizes match Japanese Excel: B4/B5/B6 are the JIS sizes, ISO B4/B5/B6 stay reachable by name, and Japanese Postcard (はがき), Japanese Double Postcard (往復はがき), the chou/kaku envelopes and A6 are available. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) | +| Charts and sparklines | 8 | ~50% | ~30% | 40–60 | 25 chart kinds, all 2-D. The chart model holds title, legend position, a data-labels flag, gridlines flag and axis titles; no axis scaling, error bars, per-point formatting, chart sheets, 3-D, surface, map, Pareto. Kinds survive XLSX round trip (#158, #161). [chart-parity.md](chart-parity.md) | +| Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, page order, Page Break Preview, headers/footers, PDF export and printing. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) | | What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables, Analysis ToolPak (13 of 19 tools, no charts), Solver (Simplex LP, a local nonlinear method and Evolutionary; Answer and Limits reports; Load/Save Model, Reset All and Solver Options; no Sensitivity report), Forecast Sheet. Missing: Analyze Data | | Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) | | Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes. Missing: SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) | @@ -335,6 +339,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| | 2026-11-01 | minor | Charts: data tables for column, bar, line, area and compatible combo charts, with legend keys and borders; live source values, native XLSX round-trip and shared print/PDF rendering. Readiness estimates unchanged. | +| 2026-11-01 | minor | Page layout and print: Page Setup's page order (Down, then over or Over, then down), kept in XLSX; no score change | | 2026-10-31 | minor | The Insert Function dialog is named as in Excel (it was called Formula Builder); no score change | | 2026-10-31 | minor | What-if and analysis: Solver Options (no score change) | | 2026-10-31 | minor | What-if and analysis: Solver Load/Save Model and Reset All; Forecast Sheet moved from Missing to the list of tools, as gaps.md §13 records (no score change) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 1c5fd0b1a..19f1c4c22 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Chart Design and Format Chart Area: data tables, legend keys and borders; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Chart Design and Format Chart Area: data tables, legend keys and borders; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -159,7 +159,7 @@ keeps the original link and an edit can be undone in one step. Format Cells, Insert Function (search, Most Recently Used, All and each category), Find and Replace, Go To and Go To Special, Sort, -Name Manager, Data Validation, Paste Special, Conditional Formatting manager, Page Setup, Goal +Name Manager, Data Validation, Paste Special, Conditional Formatting manager, Page Setup (margins and the page order), Goal Seek, Scenario Manager, Data Analysis, Forecast Sheet, Macros (macros, Automate scripts and a note on a VBA project; Run, Step Into, Edit, Create, Delete, Options), Record Macro, the macro step editor (Excel's VBA editor has no counterpart here) and others exist. Missing or partial: Options, @@ -197,6 +197,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| | 2026-11-01 | minor | Chart Design › Add Chart Element offers Data Table with/no legend keys and None; Format Chart Area has a Data Table section with visibility, legend keys, horizontal/vertical borders and outline. Both use engine commands. | +| 2026-11-01 | minor | Page Setup has a Page order choice (Down, then over, the default, or Over, then down) with a small picture of the order, also when Print Titles opens it on its Sheet tab; OK sets it with the margins in one undo step. Printing, PDF export, the print preview, Page Break Preview's page numbers and Page Layout view's headers and footers follow the order. The dialog is in German now too | | 2026-10-31 | minor | Esc in a File-page text field leaves the field and cancels editing; the next Esc returns to the sheet (#3035) | | 2026-10-31 | minor | Recently Used lists the functions most recently entered in a formula from any source, kept with the preferences; the Insert Function dialog is titled as in Excel, its category list (Most Recently Used, All, each category) works, and its category column follows the widest name shown | | 2026-10-31 | minor | Formulas › Function Library: Insert Function, the AutoSum split button and three columns of labelled small buttons with category colours; category menus end with Insert Function…; More Functions cascades; Home › Editing shares the AutoSum menu; MR key tip for Recently Used |