From 1f5c4747df25c4ef70f65e8fec7705bbc46ee37c Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:41 +0000 Subject: [PATCH] refactor(engine): move cmd/print tests to src/cmd/print/tests.rs --- crates/engine/src/cmd/print.rs | 573 +-------------------------- crates/engine/src/cmd/print/tests.rs | 570 ++++++++++++++++++++++++++ 2 files changed, 571 insertions(+), 572 deletions(-) create mode 100644 crates/engine/src/cmd/print/tests.rs diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index f6bfc6f17..d55841f7b 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -1864,575 +1864,4 @@ fn page_setup(s: &mut Session, p: &Json) -> Result { } #[cfg(test)] -mod tests { - use serde_json::json; - - use super::*; - - fn s() -> Session { - let mut s = Session::new(); - s.new_workbook(); - s - } - fn pdf_of(s: &mut Session, p: Json) -> (Vec, u64) { - let r = s.execute("file.exportPdf", p).unwrap(); - let bytes = crate::io::base64_decode(r["base64"].as_str().unwrap()).unwrap(); - (bytes, r["pages"].as_u64().unwrap()) - } - fn contains(hay: &[u8], needle: &str) -> bool { - hay.windows(needle.len()).any(|w| w == needle.as_bytes()) - } - - #[test] - fn pdf_export_keeps_safe_links_on_their_printed_pages() { - let mut s = s(); - s.execute("insert.link", json!({"cell": "A1", "target": "https://example.org/report"})).unwrap(); - s.execute("insert.link", json!({"cell": "A2", "target": "Sheet1!A60"})).unwrap(); - s.execute("insert.link", json!({"cell": "A3", "target": "file:///private/report"})).unwrap(); - s.execute("cell.set", json!({"cell": "A60", "input": "Destination"})).unwrap(); - let (pdf, pages) = pdf_of(&mut s, json!({})); - assert_eq!(pages, 2); - let text = String::from_utf8_lossy(&pdf); - assert_eq!(text.matches("/Subtype /Link").count(), 2); - assert!(text.contains("/URI (https://example.org/report)")); - assert!(text.contains("/Dest [") && text.contains("/XYZ ")); - assert!(!text.contains("/URI (file:")); - - // A link to a cell omitted by the selected print range must not point to a nonexistent - // destination. The external link in the selected range remains clickable. - let (cropped, _) = pdf_of(&mut s, json!({"range": "A1:A3"})); - let cropped = String::from_utf8_lossy(&cropped); - assert_eq!(cropped.matches("/Subtype /Link").count(), 1); - assert!(!cropped.contains("/Dest [")); - } - - #[test] - fn header_sections_split_join_and_field_names() { - let spec = "&L&\"Arial,Bold\"Sales &&Co&CPage &P of &N&R&D"; - let parts = split_header_footer(spec); - assert_eq!(parts, ["&\"Arial,Bold\"Sales &&Co".to_string(), "Page &P of &N".into(), "&D".into()]); - assert_eq!(join_header_footer(&parts), spec); - // Text before any section code is the centre section, as when printing. - assert_eq!(split_header_footer("Report"), [String::new(), "Report".into(), String::new()]); - assert_eq!(codes_to_fields("Page &P of &N, &A (&F) &&"), "Page &[Page] of &[Pages], &[Tab] (&[File]) &&"); - assert_eq!(fields_to_codes("Page &[Page] of &[Pages], &[Tab] (&[File]) &&"), "Page &P of &N, &A (&F) &&"); - // A dangling ampersand stays literal instead of eating the next section's code. - assert_eq!(fields_to_codes("R&"), "R&&"); - let joined = join_header_footer(&[fields_to_codes("R&"), "x".into(), String::new()]); - assert_eq!(split_header_footer(&joined)[0], "R&&"); - let cx = HfContext { page: 2, pages: 5, sheet: "Data".into(), ..Default::default() }; - let out = expand_header_footer(&join_header_footer(&[String::new(), fields_to_codes("&[Page] / &[Pages]"), fields_to_codes("&[Tab]")]), &cx); - assert_eq!((out[1].text.as_str(), out[2].text.as_str()), ("2 / 5", "Data")); - // Hostile input never panics. - for t in ["&", "&\"", "&\"unterminated", "&&&", "&[Pag", "\u{1F600}&[Page]"] { - let _ = join_header_footer(&split_header_footer(t)); - let _ = fields_to_codes(&codes_to_fields(t)); - } - } - - #[test] - fn header_footer_date_follows_the_workbook_date_system() { - let mut wb = Workbook::new(); - let date_1900 = hf_date_time(&wb).0; - wb.date_system = gridcraft_core::DateSystem::D1904; - assert_eq!(hf_date_time(&wb).0, date_1900); - } - - #[test] - fn header_footer_section_edits_are_undoable_and_views_are_per_sheet() { - let mut s = s(); - s.execute("pageLayout.headerFooter", json!({"header": "&LLeft&RRight"})).unwrap(); - let r = s.execute("pageLayout.headerFooter", json!({"part": "header", "section": "center", "text": "Page &[Page] of &[Pages]"})).unwrap(); - assert_eq!(r["header"], "&LLeft&CPage &P of &N&RRight"); - s.execute("pageLayout.headerFooter", json!({"part": "footer", "section": "right", "text": "&[Date]"})).unwrap(); - assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().print.footer, "&R&D"); - s.execute("edit.undo", json!({})).unwrap(); - s.execute("edit.undo", json!({})).unwrap(); - assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().print.header, "&LLeft&RRight"); - assert!(s.execute("pageLayout.headerFooter", json!({"part": "middle", "text": "x"})).is_err()); - assert!(s.execute("pageLayout.headerFooter", json!({"section": "top"})).is_err()); - let long = "x".repeat(1000); - s.execute("pageLayout.headerFooter", json!({"part": "header", "section": "left", "text": long})).unwrap(); - assert!(s.doc().unwrap().wb.sheet(0).unwrap().print.header.chars().count() <= 255); - // The view is per sheet and doesn't dirty a clean workbook. - let mut fresh = Session::new(); - fresh.new_workbook(); - fresh.execute("home.insertSheet", json!({})).unwrap(); - fresh.doc_mut().unwrap().saved = fresh.doc().unwrap().wb.clone(); - fresh.execute("view.pageLayout", json!({"sheet": 0})).unwrap(); - let wb = &fresh.doc().unwrap().wb; - assert_eq!((wb.sheet(0).unwrap().view, wb.sheet(1).unwrap().view), (SheetViewMode::PageLayout, SheetViewMode::Normal)); - assert!(!fresh.doc().unwrap().is_dirty(), "switching views is not an edit"); - s.execute("view.pageLayout", json!({"sheet": 0})).unwrap(); - s.execute("view.normal", json!({"sheet": 0})).unwrap(); - assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().view, SheetViewMode::Normal); - assert_eq!(s.execute("view.ruler", json!({"on": false})).unwrap()["on"], false); - assert!(s.hide_ruler); - assert_eq!(s.execute("view.ruler", json!({})).unwrap()["on"], true); - } - - #[test] - fn page_count_300_rows_letter_portrait() { - let mut s = s(); - let rows: Vec> = (1..=300).map(|i| vec![json!(i)]).collect(); - s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap(); - // 684pt of printable height / 15pt rows = 45 rows per page → 7 pages. - let (pdf, pages) = pdf_of(&mut s, json!({})); - assert_eq!(pages, 7); - assert!(pdf.starts_with(b"%PDF-1.7")); - assert!(contains(&pdf, "/Count 7")); - let pv = s.execute("file.printPreview", json!({})).unwrap(); - assert_eq!(pv["pages"], 7); - assert_eq!(pv["pageList"][0]["rows"], "1:45"); - assert_eq!(pv["pageList"][1]["rows"], "46:90"); - assert_eq!(s.execute("file.printPreview", json!({"page": 7})).unwrap()["page"]["rows"], "271:300"); - assert!(s.execute("file.printPreview", json!({"page": 8})).is_err()); - // Landscape: 504pt / 15 = 33 rows per page → 10 pages. - s.execute("pageLayout.pageSetup", json!({"orientation": "landscape"})).unwrap(); - assert_eq!(pdf_of(&mut s, json!({})).1, 10); - // Fit to one page. - assert_eq!(pdf_of(&mut s, json!({"fitToPage": true})).1, 1); - s.execute("pageLayout.pageSetup", json!({"fitWidth": 1, "fitHeight": 2, "orientation": "portrait"})).unwrap(); - assert_eq!(pdf_of(&mut s, json!({})).1, 2); - // Manual break and repeated title rows. - s.execute("pageLayout.pageSetup", json!({"scale": 100, "rowBreaks": [11], "titleRows": "1:1"})).unwrap(); - let pv = s.execute("file.printPreview", json!({})).unwrap(); - assert_eq!(pv["pageList"][0]["rows"], "1:10"); - // Title row takes one line on later pages: 44 body rows. - assert_eq!(pv["pageList"][1]["rows"], "11:54"); - } - - #[test] - fn wide_sheet_goes_down_then_over() { - let mut s = s(); - let rows: Vec> = (0..60).map(|r| (0..15).map(|c| json!(r * 100 + c)).collect()).collect(); - s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap(); - let pv = s.execute("file.printPreview", json!({})).unwrap(); - // 511.2pt / 48pt = 10 columns per page; 45 rows per page. - assert_eq!(pv["pages"], 4); - assert_eq!(pv["pageList"][0]["range"], "A1:J45"); - assert_eq!(pv["pageList"][1]["range"], "A46:J60"); - assert_eq!(pv["pageList"][2]["range"], "K1:O45"); - } - - #[test] - fn first_and_even_pages_print_their_own_headers() { - let mut s = s(); - s.execute("range.setValues", json!({"range": "A1", "values": [["a"], ["b"], ["c"], ["d"], ["e"], ["f"]]})).unwrap(); - s.execute("pageLayout.pageSetup", json!({"rowBreaks": [2, 4]})).unwrap(); - let r = s - .execute( - "pageLayout.headerFooter", - json!({"header": "&COdd &P", "evenHeader": "&CEven &P", "firstHeader": "&CCover", "differentFirst": true, "differentOddEven": true}), - ) - .unwrap(); - assert_eq!(r["firstHeader"], "&CCover"); - let (pdf, pages) = pdf_of(&mut s, json!({})); - assert_eq!(pages, 3); - assert!(contains(&pdf, "(Cover) Tj") && contains(&pdf, "(Even 2) Tj") && contains(&pdf, "(Odd 3) Tj")); - assert!(!contains(&pdf, "(Odd 1) Tj"), "the first page has its own header"); - // One section of the even-page footer, edited in place. - s.execute("pageLayout.headerFooter", json!({"page": "even", "part": "footer", "section": "right", "text": "&[Tab]"})).unwrap(); - assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().print.even_footer, "&R&A"); - assert!(s.execute("pageLayout.headerFooter", json!({"page": "last", "part": "header", "text": "x"})).is_err()); - // Turned off, every page prints the odd header again. - s.execute("pageLayout.headerFooter", json!({"differentFirst": false, "differentOddEven": false})).unwrap(); - let (pdf, _) = pdf_of(&mut s, json!({})); - assert!(contains(&pdf, "(Odd 1) Tj") && contains(&pdf, "(Odd 2) Tj") && !contains(&pdf, "(Cover) Tj")); - } - - /// The x position and font size of the first `(text) Tj` in a PDF. - fn text_at(pdf: &[u8], text: &str) -> Option<(f32, f32)> { - let s = String::from_utf8_lossy(pdf); - let before = &s[..s.find(&format!("({text}) Tj"))?]; - let tf = before.rfind(" Tf")?; - let size = before[..tf].rsplit(' ').next()?.parse().ok()?; - let tm = before.rfind(" Tm")?; - let mut nums = before[..tm].rsplit([' ', '\n']); - let _y = nums.next()?; - let x = nums.next()?.parse().ok()?; - Some((x, size)) - } - - #[test] - fn headers_scale_with_the_sheet_and_align_with_its_margins_unless_turned_off() { - let mut s = s(); - s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap(); - s.execute("pageLayout.pageSetup", json!({"scale": 50, "left": 1.5, "right": 1.5})).unwrap(); - s.execute("pageLayout.headerFooter", json!({"header": "&LLeft&C&14Middle"})).unwrap(); - // Both on (the default): the text prints at half size, from the 1.5-inch left margin. - let (pdf, _) = pdf_of(&mut s, json!({})); - assert_eq!(text_at(&pdf, "Left"), Some((108.0, 5.0))); - assert_eq!(text_at(&pdf, "Middle").map(|t| t.1), Some(7.0)); - let r = s.execute("pageLayout.headerFooter", json!({"scaleWithDoc": false, "alignWithMargins": false})).unwrap(); - assert_eq!((&r["scaleWithDoc"], &r["alignWithMargins"]), (&json!(false), &json!(false))); - // Both off: full size, 0.7 inch from the paper's left edge. - let (pdf, _) = pdf_of(&mut s, json!({})); - assert_eq!(text_at(&pdf, "Left"), Some((50.4, 10.0))); - assert_eq!(text_at(&pdf, "Middle").map(|t| t.1), Some(14.0)); - s.execute("edit.undo", json!({})).unwrap(); - let pr = &s.doc().unwrap().wb.sheet(0).unwrap().print; - assert!(pr.hf_scale_with_doc && pr.hf_align_with_margins); - } - - #[test] - fn pdf_prints_cjk_text_instead_of_question_marks() { - let mut s = s(); - s.execute("range.setValues", json!({"range": "A1", "values": [["签字清单", 2]]})).unwrap(); - let (pdf, _) = pdf_of(&mut s, json!({})); - assert!(!contains(&pdf, "(????) Tj"), "CJK cell printed as (????) Tj"); - assert!(contains(&pdf, "<7B7E5B576E055355> Tj")); - assert!(contains(&pdf, "(2) Tj")); - } - - #[test] - fn pdf_contains_text_and_header_codes_expand() { - let mut s = s(); - s.execute("range.setValues", json!({"range": "A1", "values": [["Quarterly Report", null], ["Total", 1234.5]]})).unwrap(); - s.execute("home.bold", json!({"range": "A1"})).unwrap(); - s.execute( - "pageLayout.pageSetup", - json!({"header": "&LLeft &A&CMiddle&RPage &P of &N", "footer": "&C&F", "gridlines": true, "headings": true}), - ) - .unwrap(); - let (pdf, pages) = pdf_of(&mut s, json!({})); - assert_eq!(pages, 1); - assert!(contains(&pdf, "(Quarterly Report) Tj")); - assert!(contains(&pdf, "(1234.5) Tj")); - assert!(contains(&pdf, "(Left Sheet1) Tj")); - assert!(contains(&pdf, "(Page 1 of 1) Tj")); - assert!(contains(&pdf, "(Book1) Tj")); - assert!(contains(&pdf, "/Helvetica-Bold")); - let cx = HfContext { - page: 3, - pages: 9, - sheet: "Data".into(), - file: "b.xlsx".into(), - date: "1/2/2026".into(), - time: "9:00 AM".into(), - ..Default::default() - }; - let [l, c, r] = expand_header_footer("&L&\"Arial,Bold\"&A &D&C&14&BTitle && more&RP&P+1/&N &T", &cx); - assert_eq!(l.text, "Data 1/2/2026"); - assert!(l.bold); - assert_eq!(c.text, "Title & more"); - assert!(c.bold); - assert_eq!(c.size, Some(14.0)); - assert_eq!(r.text, "P4/9 9:00 AM"); - assert_eq!(expand_header_footer("plain", &cx)[1].text, "plain"); - assert_eq!(expand_header_footer("&", &cx)[1].text, ""); - } - - #[test] - fn conditional_number_format_prints() { - // #1163: a rule's number format replaces the cell's own, whether that is General or not; - // cells the rule doesn't match keep theirs. - let mut s = s(); - s.execute("range.setValues", json!({"range": "A1", "values": [[1], [3], [4]]})).unwrap(); - s.execute("home.numberFormat", json!({"range": "A3", "code": "0.0"})).unwrap(); - let rule = json!({"type": "cellIs", "operator": "greater", "value": "2", "style": {"num_fmt": "0.00"}}); - s.execute("home.conditionalFormat", json!({"range": "A1:A3", "rule": rule})).unwrap(); - let (pdf, _) = pdf_of(&mut s, json!({})); - assert!(contains(&pdf, "(1) Tj"), "an unmatched cell keeps General"); - assert!(contains(&pdf, "(3.00) Tj") && !contains(&pdf, "(3) Tj"), "General cell under the rule"); - assert!(contains(&pdf, "(4.00) Tj") && !contains(&pdf, "(4.0) Tj"), "formatted cell under the rule"); - } - - #[test] - fn pdf_uses_indexed_number_format_palette_color() { - let mut s = s(); - s.execute("cell.set", json!({"cell": "A1", "input": "42"})).unwrap(); - s.execute("home.numberFormat", json!({"range": "A1", "format": "[Color4]0"})).unwrap(); - let (pdf, pages) = pdf_of(&mut s, json!({})); - assert_eq!(pages, 1); - assert!(contains(&pdf, "0 1 0 rg"), "expected indexed Color4 green in PDF text stream"); - } - - #[test] - fn export_writes_file() { - let mut s = s(); - s.execute("cell.set", json!({"cell": "B2", "input": "hello"})).unwrap(); - let dir = std::env::temp_dir().join(format!("gridcraft-print-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("out.pdf"); - let r = s.execute("file.exportPdf", json!({"path": path.to_str().unwrap()})).unwrap(); - assert_eq!(r["pages"], 1); - let bytes = std::fs::read(&path).unwrap(); - assert!(bytes.starts_with(b"%PDF-")); - assert!(contains(&bytes, "(hello) Tj")); - let _ = std::fs::remove_dir_all(&dir); - let r = s.execute("file.print", json!({})).unwrap(); - assert!(r["base64"].as_str().unwrap().len() > 100); - } - - #[test] - fn save_as_pdf_writes_a_pdf_and_keeps_the_workbook_file() { - let mut s = s(); - s.execute("cell.set", json!({"cell": "B2", "input": "hello"})).unwrap(); - let dir = std::env::temp_dir().join(format!("gridcraft-save-pdf-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("Out.PDF"); - let r = s.execute("file.saveAs", json!({"path": path.to_str().unwrap()})).unwrap(); - assert_eq!(r["pages"], 1); - let bytes = std::fs::read(&path).unwrap(); - assert!(bytes.starts_with(b"%PDF-")); - assert!(contains(&bytes, "(hello) Tj")); - // The PDF is a print, not the workbook's file: Save still asks where to put the workbook. - let d = s.doc().unwrap(); - assert!(d.path.is_none() && d.is_dirty()); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn rich_sheet_renders() { - let mut s = Session::new(); - s.execute("file.new", json!({"sample": "sales"})).unwrap(); - s.execute("range.setValues", json!({"range": "H1", "values": [["A long piece of text that overflows into neighbours"]]})).unwrap(); - s.execute("cell.set", json!({"cell": "H3", "input": "Wrapped text across several lines in a narrow cell"})).unwrap(); - s.execute("home.wrapText", json!({"range": "H3"})).unwrap_or_default(); - s.execute("selection.set", json!({"range": "J5:K6"})).unwrap(); - s.execute("home.mergeCenter", json!({})).unwrap(); - s.execute("cell.set", json!({"cell": "J5", "input": "Merged"})).unwrap(); - s.execute("home.borders", json!({"range": "J5:K6", "preset": "outside", "style": "thick"})).unwrap_or_default(); - let (pdf, pages) = pdf_of(&mut s, json!({"sheets": "all"})); - assert!(pages >= 1); - assert!(contains(&pdf, "(Merged) Tj")); - assert!(contains(&pdf, "(A long piece of text that overflows into neighbours) Tj")); - } - - #[test] - fn charts_and_images_are_drawn() { - let mut s = s(); - s.execute("range.setValues", json!({"range": "A1", "values": [["Fruit", "Qty"], ["Apples", 3], ["Pears", 5]]})).unwrap(); - s.execute("insert.chart", json!({"range": "A1:B3", "type": "pie"})).unwrap_or_default(); - s.execute("insert.chart", json!({"range": "A1:B3"})).unwrap_or_default(); - // A 2×2 PNG with transparency. - let mut png = Vec::new(); - let img = image::RgbaImage::from_raw(2, 2, vec![255, 0, 0, 255, 0, 255, 0, 128, 0, 0, 255, 255, 0, 0, 0, 0]).unwrap(); - image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); - let d = s.doc_mut().unwrap(); - let wb = std::sync::Arc::make_mut(&mut d.wb); - let id = wb.next_object_id(); - wb.sheet_mut(0).unwrap().images.push(Image { - id, - anchor: Anchor { cell: CellRef::new(5, 5), dx: 0.0, dy: 0.0, width: 40.0, height: 40.0, mode: gridcraft_model::AnchorMode::MoveAndSize }, - data: png.into(), - mime: "image/png".into(), - alt: String::new(), - }); - let charts = s.doc().unwrap().wb.active().unwrap().charts.len(); - let (pdf, _) = pdf_of(&mut s, json!({})); - assert!(contains(&pdf, "/Subtype /Image")); - assert!(contains(&pdf, "/SMask")); - if charts > 0 { - assert!(contains(&pdf, "(Apples) Tj") || contains(&pdf, "(Qty) Tj")); - } - } - - #[test] - fn page_setup_edits_settings() { - let mut s = s(); - let r = s - .execute( - "pageLayout.pageSetup", - json!({"paper": "A4", "orientation": "landscape", "margins": {"left": 1, "top": 0.5}, "right": 0.25, "centerH": true, "printArea": "A1:D20", "titleRows": "1:2", "titleCols": "A:A", "colBreaks": ["C"]}), - ) - .unwrap(); - assert_eq!(r["paper"], "A4"); - assert_eq!(r["orientation"], "Landscape"); - assert_eq!(r["margins"][0], 1.0); - assert_eq!(r["margins"][1], 0.25); - assert_eq!(r["margins"][2], 0.5); - assert_eq!(r["center_h"], true); - assert_eq!(r["title_rows"], json!([0, 1])); - assert_eq!(r["col_breaks"], json!([2])); - let sh = s.doc().unwrap().wb.active().unwrap().print.clone(); - assert_eq!(sh.print_area, RangeRef::parse("A1:D20")); - s.execute("pageLayout.pageSetup", json!({"printArea": ""})).unwrap(); - assert_eq!(s.doc().unwrap().wb.active().unwrap().print.print_area, None); - assert!(s.execute("pageLayout.pageSetup", json!({"paper": "Napkin"})).is_err()); - assert!(s.execute("pageLayout.pageSetup", json!({"orientation": "sideways"})).is_err()); - // Undoable as one step. - s.execute("edit.undo", json!({})).unwrap(); - assert!(s.doc().unwrap().wb.active().unwrap().print.print_area.is_some()); - } - - /// The Japanese paper sizes are the JIS ones: `B4`/`B5`/`B6` changed meaning from ISO, and the - /// ISO B series stays reachable by name. The postcards and envelopes are the JIS sizes too. - #[test] - fn japanese_paper_sizes_are_jis() { - for (name, w, h) in [ - ("A4", 595.28, 841.89), // unchanged - ("B4", 728.50, 1031.81), - ("B5", 515.91, 728.50), - ("B6", 362.83, 515.91), - ("B4 (JIS)", 728.50, 1031.81), - ("b5-jis", 515.91, 728.50), - ("B4 (ISO)", 708.66, 1000.63), - ("B5 (ISO)", 498.90, 708.66), - ("B6 (ISO)", 354.33, 498.90), - ("A6", 297.64, 419.53), - ("Japanese Postcard", 283.46, 419.53), - ("japanese double postcard", 419.53, 566.93), - ("Japanese Envelope #3", 340.16, 666.14), - ("chou #4", 255.12, 581.10), - ("Japanese Envelope Kaku #2", 680.31, 941.10), - ("kaku #3", 612.28, 785.20), - ] { - let got = paper_size(name); - assert!((got.0 - w).abs() < 0.01 && (got.1 - h).abs() < 0.01, "{name}: {got:?}"); - } - // 長形4号 is 90×205 mm (90×225 mm is 長形40号), so its height is 205 mm = 581.10 pt — not the - // 225 mm it once had. - let chou4 = paper_size("Japanese Envelope #4"); - assert_eq!((chou4.0, chou4.1), (255.12, 581.10)); - assert_eq!(chou4, paper_size("chou #4")); - // Unknown names keep the old Letter fallback. - assert_eq!(paper_size("Napkin"), (612.0, 792.0)); - } - - /// Page Setup's paper reaches the PDF page: the MediaBox is the paper's, A4 is unchanged, B5 is - /// JIS B5, and the Japanese postcard prints at 283.46×419.53 pt. Landscape swaps the sides and - /// fit-to-page keeps the paper size. - #[test] - fn page_setup_paper_sizes_reach_the_pdf_page() { - let mut s = s(); - s.execute("range.setValues", json!({"range": "A1", "values": [["x"]]})).unwrap(); - let close = |v: &Json, want: f64| (v.as_f64().unwrap() - want).abs() < 0.01; - for (paper, w, h) in [ - ("A4", 595.28f64, 841.89f64), - ("B5", 515.91, 728.50), - ("Japanese Postcard", 283.46, 419.53), - ("Japanese Double Postcard", 419.53, 566.93), - ("Japanese Envelope Kaku #2", 680.31, 941.10), - ] { - s.execute("pageLayout.pageSetup", json!({"paper": paper, "orientation": "portrait"})).unwrap(); - let pv = s.execute("file.printPreview", json!({})).unwrap(); - let page = &pv["pageList"][0]; - assert_eq!(page["paper"], paper); - assert!(close(&page["width"], w) && close(&page["height"], h), "{paper}: {page}"); - let (pdf, pages) = pdf_of(&mut s, json!({})); - assert_eq!(pages, 1); - let want = format!("/MediaBox [0 0 {w} {h}]"); - assert!(contains(&pdf, &want), "{paper}: wanted {want}"); - } - // Landscape swaps the sides, and fit-to-page does not change the paper. - s.execute("pageLayout.pageSetup", json!({"paper": "B5", "orientation": "landscape"})).unwrap(); - let (pdf, pages) = pdf_of(&mut s, json!({})); - assert_eq!(pages, 1); - assert!(contains(&pdf, "/MediaBox [0 0 728.5 515.91]")); - s.execute("pageLayout.pageSetup", json!({"paper": "B5", "orientation": "portrait"})).unwrap(); - let (pdf, _) = pdf_of(&mut s, json!({"fitToPage": true})); - assert!(contains(&pdf, "/MediaBox [0 0 515.91 728.5]")); - // `pageLayout.size` still takes any name, and an unknown one prints on Letter as before. - s.execute("pageLayout.size", json!({"paper": "Napkin"})).unwrap(); - let pv = s.execute("file.printPreview", json!({})).unwrap(); - assert!(close(&pv["pageList"][0]["width"], 612.0) && close(&pv["pageList"][0]["height"], 792.0)); - // Page Setup refuses an unknown name. - assert!(s.execute("pageLayout.pageSetup", json!({"paper": "Napkin"})).is_err()); - } - - #[test] - fn hostile_settings_never_panic_and_cap_pages() { - let mut s = s(); - s.execute("cell.set", json!({"cell": "XFD1048576", "input": "far"})).unwrap(); - s.execute("cell.set", json!({"cell": "A1", "input": "near"})).unwrap(); - s.execute("pageLayout.pageSetup", json!({"scale": 400, "margins": {"left": 20, "right": 20, "top": 20, "bottom": 20}})).unwrap(); - let pv = s.execute("file.printPreview", json!({})).unwrap(); - assert!(pv["pages"].as_u64().unwrap() <= MAX_PAGES as u64); - 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); - 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(); - assert!(pv["pages"].as_u64().unwrap() <= MAX_PAGES as u64); - assert!(s.execute("file.exportPdf", json!({"range": "A1:C3"})).is_ok()); - assert!(s.execute("file.exportPdf", json!({"range": "nope"})).is_err()); - assert!(s.execute("file.exportPdf", json!({"sheets": "some"})).is_err()); - // Empty workbook still prints one blank page. - let mut e = Session::new(); - e.new_workbook(); - let r = e.execute("file.exportPdf", json!({})).unwrap(); - assert_eq!(r["pages"], 1); - let r = e.execute("file.exportPdf", json!({"sheets": "all"})).unwrap(); - assert_eq!(r["pages"], 1); - // Weird row sizes and huge fonts. - s.execute("pageLayout.pageSetup", json!({"fitToPage": false, "scale": 10, "printArea": "A1:B3", "titleRows": "", "titleCols": ""})).unwrap(); - s.execute("home.fontSize", json!({"range": "A1", "size": 409})).unwrap_or_default(); - assert!(s.execute("file.exportPdf", json!({})).is_ok()); - } - - #[test] - fn export_pdf_without_a_path_returns_bytes_and_never_opens_a_dialog() { - let mut s = s(); - for params in [json!({}), json!({"path": ""}), json!({"path": null})] { - let r = s.execute("file.exportPdf", params).unwrap(); - assert!(r.get("path").is_none(), "no file is written without a path"); - let bytes = crate::io::base64_decode(r["base64"].as_str().unwrap()).unwrap(); - assert!(bytes.starts_with(b"%PDF-")); - } - assert!(s.take_ui_requests().is_empty(), "a programmatic call never queues a dialog"); - // Headless callers (CLI, MCP) get the same bytes, not an error. - assert!(s.execute_headless("file.exportPdf", json!({})).is_ok()); - // With no workbook open it is a clear error, not a panic. - let mut none = Session::new(); - assert!(none.execute("file.exportPdf", json!({})).is_err()); - } - - #[test] - fn bands_unit() { - let b = bands(0, 9, |_| 10.0, |_| 35.0, &[], 100); - assert_eq!(b, vec![(0, 2), (3, 5), (6, 8), (9, 9)]); - let b = bands(0, 9, |_| 10.0, |_| 35.0, &[5], 100); - assert_eq!(b, vec![(0, 2), (3, 4), (5, 7), (8, 9)]); - // A line larger than the page gets its own band. - let b = bands(0, 2, |r| if r == 1 { 100.0 } else { 10.0 }, |_| 35.0, &[], 100); - assert_eq!(b, vec![(0, 0), (1, 1), (2, 2)]); - assert_eq!(bands(0, 1_000_000, |_| 10.0, |_| 5.0, &[], 3).len(), 3); - let w = wrap_lines("the quick brown fox jumps", Font::Helvetica, 10.0, 50.0); - assert!(w.len() >= 2); - assert!(w.iter().all(|l| gridcraft_pdf::text_width(Font::Helvetica, 10.0, l) <= 50.0 || !l.contains(' '))); - let w = wrap_lines("Supercalifragilistic", Font::Helvetica, 10.0, 20.0); - assert!(w.len() > 2); - } - - #[test] - fn center_across_spans_following_empty_center_across_cells() { - let mut styles = StyleTable::default(); - let across = styles.intern(Style { align: Alignment { h: HAlign::CenterAcross, ..Alignment::default() }, ..Style::default() }); - let mut sh = Sheet::new("S"); - for col in 0..3 { - sh.set_style(CellRef::new(0, col), across); - } - sh.set_value(CellRef::new(0, 0), Value::Text("Title".into())); - let a1 = CellRef::new(0, 0); - assert_eq!(center_across_width(&styles, &sh, a1, &[]), sh.col_width(1) + sh.col_width(2)); - sh.set_value(CellRef::new(0, 2), Value::Text("x".into())); - assert_eq!(center_across_width(&styles, &sh, a1, &[]), sh.col_width(1)); - let merged = [RangeRef::new(CellRef::new(0, 1), CellRef::new(1, 1))]; - assert_eq!(center_across_width(&styles, &sh, a1, &merged), 0.0); - } - - #[test] - fn rotated_wrapped_text_prints_rotated() { - // Upright headers over narrow columns (mark sheets) combine rotation with Wrap Text. - let mut s = s(); - let header = "Term 1 : Drama : Continuous Assessment"; - s.execute("range.setValues", json!({"range": "A1", "values": [[header, header]]})).unwrap(); - s.execute("home.wrapText", json!({"range": "A1:B1", "on": true})).unwrap(); - s.execute("home.orientation", json!({"range": "A1", "angle": "up"})).unwrap(); - s.execute("home.orientation", json!({"range": "B1", "angle": "down"})).unwrap(); - s.execute("home.columnWidth", json!({"cols": "A:B", "chars": 6})).unwrap(); - s.execute("home.rowHeight", json!({"rows": "1:1", "height": 267})).unwrap(); - let (pdf, _) = pdf_of(&mut s, json!({})); - let pdf = String::from_utf8_lossy(&pdf); - // The page holds only these two cells, so every text block on it is a piece of a header. - let blocks: Vec<&str> = pdf.split("BT\n").skip(1).filter(|b| b.contains(" Tj")).collect(); - let up = blocks.iter().filter(|b| b.contains("\n0 1 -1 0 ")).count(); - let down = blocks.iter().filter(|b| b.contains("\n0 -1 1 0 ")).count(); - // Every piece of both headers is rotated, each wrapped along the row height into a few long lines. - assert_eq!(up + down, blocks.len(), "a header line printed flat: {blocks:?}"); - assert!((1..=3).contains(&up) && (1..=3).contains(&down), "up {up}, down {down}: {blocks:?}"); - } -} +mod tests; diff --git a/crates/engine/src/cmd/print/tests.rs b/crates/engine/src/cmd/print/tests.rs new file mode 100644 index 000000000..89b7b62a7 --- /dev/null +++ b/crates/engine/src/cmd/print/tests.rs @@ -0,0 +1,570 @@ +use serde_json::json; + +use super::*; + +fn s() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s +} +fn pdf_of(s: &mut Session, p: Json) -> (Vec, u64) { + let r = s.execute("file.exportPdf", p).unwrap(); + let bytes = crate::io::base64_decode(r["base64"].as_str().unwrap()).unwrap(); + (bytes, r["pages"].as_u64().unwrap()) +} +fn contains(hay: &[u8], needle: &str) -> bool { + hay.windows(needle.len()).any(|w| w == needle.as_bytes()) +} + +#[test] +fn pdf_export_keeps_safe_links_on_their_printed_pages() { + let mut s = s(); + s.execute("insert.link", json!({"cell": "A1", "target": "https://example.org/report"})).unwrap(); + s.execute("insert.link", json!({"cell": "A2", "target": "Sheet1!A60"})).unwrap(); + s.execute("insert.link", json!({"cell": "A3", "target": "file:///private/report"})).unwrap(); + s.execute("cell.set", json!({"cell": "A60", "input": "Destination"})).unwrap(); + let (pdf, pages) = pdf_of(&mut s, json!({})); + assert_eq!(pages, 2); + let text = String::from_utf8_lossy(&pdf); + assert_eq!(text.matches("/Subtype /Link").count(), 2); + assert!(text.contains("/URI (https://example.org/report)")); + assert!(text.contains("/Dest [") && text.contains("/XYZ ")); + assert!(!text.contains("/URI (file:")); + + // A link to a cell omitted by the selected print range must not point to a nonexistent + // destination. The external link in the selected range remains clickable. + let (cropped, _) = pdf_of(&mut s, json!({"range": "A1:A3"})); + let cropped = String::from_utf8_lossy(&cropped); + assert_eq!(cropped.matches("/Subtype /Link").count(), 1); + assert!(!cropped.contains("/Dest [")); +} + +#[test] +fn header_sections_split_join_and_field_names() { + let spec = "&L&\"Arial,Bold\"Sales &&Co&CPage &P of &N&R&D"; + let parts = split_header_footer(spec); + assert_eq!(parts, ["&\"Arial,Bold\"Sales &&Co".to_string(), "Page &P of &N".into(), "&D".into()]); + assert_eq!(join_header_footer(&parts), spec); + // Text before any section code is the centre section, as when printing. + assert_eq!(split_header_footer("Report"), [String::new(), "Report".into(), String::new()]); + assert_eq!(codes_to_fields("Page &P of &N, &A (&F) &&"), "Page &[Page] of &[Pages], &[Tab] (&[File]) &&"); + assert_eq!(fields_to_codes("Page &[Page] of &[Pages], &[Tab] (&[File]) &&"), "Page &P of &N, &A (&F) &&"); + // A dangling ampersand stays literal instead of eating the next section's code. + assert_eq!(fields_to_codes("R&"), "R&&"); + let joined = join_header_footer(&[fields_to_codes("R&"), "x".into(), String::new()]); + assert_eq!(split_header_footer(&joined)[0], "R&&"); + let cx = HfContext { page: 2, pages: 5, sheet: "Data".into(), ..Default::default() }; + let out = expand_header_footer(&join_header_footer(&[String::new(), fields_to_codes("&[Page] / &[Pages]"), fields_to_codes("&[Tab]")]), &cx); + assert_eq!((out[1].text.as_str(), out[2].text.as_str()), ("2 / 5", "Data")); + // Hostile input never panics. + for t in ["&", "&\"", "&\"unterminated", "&&&", "&[Pag", "\u{1F600}&[Page]"] { + let _ = join_header_footer(&split_header_footer(t)); + let _ = fields_to_codes(&codes_to_fields(t)); + } +} + +#[test] +fn header_footer_date_follows_the_workbook_date_system() { + let mut wb = Workbook::new(); + let date_1900 = hf_date_time(&wb).0; + wb.date_system = gridcraft_core::DateSystem::D1904; + assert_eq!(hf_date_time(&wb).0, date_1900); +} + +#[test] +fn header_footer_section_edits_are_undoable_and_views_are_per_sheet() { + let mut s = s(); + s.execute("pageLayout.headerFooter", json!({"header": "&LLeft&RRight"})).unwrap(); + let r = s.execute("pageLayout.headerFooter", json!({"part": "header", "section": "center", "text": "Page &[Page] of &[Pages]"})).unwrap(); + assert_eq!(r["header"], "&LLeft&CPage &P of &N&RRight"); + s.execute("pageLayout.headerFooter", json!({"part": "footer", "section": "right", "text": "&[Date]"})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().print.footer, "&R&D"); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().print.header, "&LLeft&RRight"); + assert!(s.execute("pageLayout.headerFooter", json!({"part": "middle", "text": "x"})).is_err()); + assert!(s.execute("pageLayout.headerFooter", json!({"section": "top"})).is_err()); + let long = "x".repeat(1000); + s.execute("pageLayout.headerFooter", json!({"part": "header", "section": "left", "text": long})).unwrap(); + assert!(s.doc().unwrap().wb.sheet(0).unwrap().print.header.chars().count() <= 255); + // The view is per sheet and doesn't dirty a clean workbook. + let mut fresh = Session::new(); + fresh.new_workbook(); + fresh.execute("home.insertSheet", json!({})).unwrap(); + fresh.doc_mut().unwrap().saved = fresh.doc().unwrap().wb.clone(); + fresh.execute("view.pageLayout", json!({"sheet": 0})).unwrap(); + let wb = &fresh.doc().unwrap().wb; + assert_eq!((wb.sheet(0).unwrap().view, wb.sheet(1).unwrap().view), (SheetViewMode::PageLayout, SheetViewMode::Normal)); + assert!(!fresh.doc().unwrap().is_dirty(), "switching views is not an edit"); + s.execute("view.pageLayout", json!({"sheet": 0})).unwrap(); + s.execute("view.normal", json!({"sheet": 0})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().view, SheetViewMode::Normal); + assert_eq!(s.execute("view.ruler", json!({"on": false})).unwrap()["on"], false); + assert!(s.hide_ruler); + assert_eq!(s.execute("view.ruler", json!({})).unwrap()["on"], true); +} + +#[test] +fn page_count_300_rows_letter_portrait() { + let mut s = s(); + let rows: Vec> = (1..=300).map(|i| vec![json!(i)]).collect(); + s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap(); + // 684pt of printable height / 15pt rows = 45 rows per page → 7 pages. + let (pdf, pages) = pdf_of(&mut s, json!({})); + assert_eq!(pages, 7); + assert!(pdf.starts_with(b"%PDF-1.7")); + assert!(contains(&pdf, "/Count 7")); + let pv = s.execute("file.printPreview", json!({})).unwrap(); + assert_eq!(pv["pages"], 7); + assert_eq!(pv["pageList"][0]["rows"], "1:45"); + assert_eq!(pv["pageList"][1]["rows"], "46:90"); + assert_eq!(s.execute("file.printPreview", json!({"page": 7})).unwrap()["page"]["rows"], "271:300"); + assert!(s.execute("file.printPreview", json!({"page": 8})).is_err()); + // Landscape: 504pt / 15 = 33 rows per page → 10 pages. + s.execute("pageLayout.pageSetup", json!({"orientation": "landscape"})).unwrap(); + assert_eq!(pdf_of(&mut s, json!({})).1, 10); + // Fit to one page. + assert_eq!(pdf_of(&mut s, json!({"fitToPage": true})).1, 1); + s.execute("pageLayout.pageSetup", json!({"fitWidth": 1, "fitHeight": 2, "orientation": "portrait"})).unwrap(); + assert_eq!(pdf_of(&mut s, json!({})).1, 2); + // Manual break and repeated title rows. + s.execute("pageLayout.pageSetup", json!({"scale": 100, "rowBreaks": [11], "titleRows": "1:1"})).unwrap(); + let pv = s.execute("file.printPreview", json!({})).unwrap(); + assert_eq!(pv["pageList"][0]["rows"], "1:10"); + // Title row takes one line on later pages: 44 body rows. + assert_eq!(pv["pageList"][1]["rows"], "11:54"); +} + +#[test] +fn wide_sheet_goes_down_then_over() { + let mut s = s(); + let rows: Vec> = (0..60).map(|r| (0..15).map(|c| json!(r * 100 + c)).collect()).collect(); + s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap(); + let pv = s.execute("file.printPreview", json!({})).unwrap(); + // 511.2pt / 48pt = 10 columns per page; 45 rows per page. + assert_eq!(pv["pages"], 4); + assert_eq!(pv["pageList"][0]["range"], "A1:J45"); + assert_eq!(pv["pageList"][1]["range"], "A46:J60"); + assert_eq!(pv["pageList"][2]["range"], "K1:O45"); +} + +#[test] +fn first_and_even_pages_print_their_own_headers() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["a"], ["b"], ["c"], ["d"], ["e"], ["f"]]})).unwrap(); + s.execute("pageLayout.pageSetup", json!({"rowBreaks": [2, 4]})).unwrap(); + let r = s + .execute( + "pageLayout.headerFooter", + json!({"header": "&COdd &P", "evenHeader": "&CEven &P", "firstHeader": "&CCover", "differentFirst": true, "differentOddEven": true}), + ) + .unwrap(); + assert_eq!(r["firstHeader"], "&CCover"); + let (pdf, pages) = pdf_of(&mut s, json!({})); + assert_eq!(pages, 3); + assert!(contains(&pdf, "(Cover) Tj") && contains(&pdf, "(Even 2) Tj") && contains(&pdf, "(Odd 3) Tj")); + assert!(!contains(&pdf, "(Odd 1) Tj"), "the first page has its own header"); + // One section of the even-page footer, edited in place. + s.execute("pageLayout.headerFooter", json!({"page": "even", "part": "footer", "section": "right", "text": "&[Tab]"})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().print.even_footer, "&R&A"); + assert!(s.execute("pageLayout.headerFooter", json!({"page": "last", "part": "header", "text": "x"})).is_err()); + // Turned off, every page prints the odd header again. + s.execute("pageLayout.headerFooter", json!({"differentFirst": false, "differentOddEven": false})).unwrap(); + let (pdf, _) = pdf_of(&mut s, json!({})); + assert!(contains(&pdf, "(Odd 1) Tj") && contains(&pdf, "(Odd 2) Tj") && !contains(&pdf, "(Cover) Tj")); +} + +/// The x position and font size of the first `(text) Tj` in a PDF. +fn text_at(pdf: &[u8], text: &str) -> Option<(f32, f32)> { + let s = String::from_utf8_lossy(pdf); + let before = &s[..s.find(&format!("({text}) Tj"))?]; + let tf = before.rfind(" Tf")?; + let size = before[..tf].rsplit(' ').next()?.parse().ok()?; + let tm = before.rfind(" Tm")?; + let mut nums = before[..tm].rsplit([' ', '\n']); + let _y = nums.next()?; + let x = nums.next()?.parse().ok()?; + Some((x, size)) +} + +#[test] +fn headers_scale_with_the_sheet_and_align_with_its_margins_unless_turned_off() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap(); + s.execute("pageLayout.pageSetup", json!({"scale": 50, "left": 1.5, "right": 1.5})).unwrap(); + s.execute("pageLayout.headerFooter", json!({"header": "&LLeft&C&14Middle"})).unwrap(); + // Both on (the default): the text prints at half size, from the 1.5-inch left margin. + let (pdf, _) = pdf_of(&mut s, json!({})); + assert_eq!(text_at(&pdf, "Left"), Some((108.0, 5.0))); + assert_eq!(text_at(&pdf, "Middle").map(|t| t.1), Some(7.0)); + let r = s.execute("pageLayout.headerFooter", json!({"scaleWithDoc": false, "alignWithMargins": false})).unwrap(); + assert_eq!((&r["scaleWithDoc"], &r["alignWithMargins"]), (&json!(false), &json!(false))); + // Both off: full size, 0.7 inch from the paper's left edge. + let (pdf, _) = pdf_of(&mut s, json!({})); + assert_eq!(text_at(&pdf, "Left"), Some((50.4, 10.0))); + assert_eq!(text_at(&pdf, "Middle").map(|t| t.1), Some(14.0)); + s.execute("edit.undo", json!({})).unwrap(); + let pr = &s.doc().unwrap().wb.sheet(0).unwrap().print; + assert!(pr.hf_scale_with_doc && pr.hf_align_with_margins); +} + +#[test] +fn pdf_prints_cjk_text_instead_of_question_marks() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["签字清单", 2]]})).unwrap(); + let (pdf, _) = pdf_of(&mut s, json!({})); + assert!(!contains(&pdf, "(????) Tj"), "CJK cell printed as (????) Tj"); + assert!(contains(&pdf, "<7B7E5B576E055355> Tj")); + assert!(contains(&pdf, "(2) Tj")); +} + +#[test] +fn pdf_contains_text_and_header_codes_expand() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Quarterly Report", null], ["Total", 1234.5]]})).unwrap(); + s.execute("home.bold", json!({"range": "A1"})).unwrap(); + s.execute( + "pageLayout.pageSetup", + json!({"header": "&LLeft &A&CMiddle&RPage &P of &N", "footer": "&C&F", "gridlines": true, "headings": true}), + ) + .unwrap(); + let (pdf, pages) = pdf_of(&mut s, json!({})); + assert_eq!(pages, 1); + assert!(contains(&pdf, "(Quarterly Report) Tj")); + assert!(contains(&pdf, "(1234.5) Tj")); + assert!(contains(&pdf, "(Left Sheet1) Tj")); + assert!(contains(&pdf, "(Page 1 of 1) Tj")); + assert!(contains(&pdf, "(Book1) Tj")); + assert!(contains(&pdf, "/Helvetica-Bold")); + let cx = HfContext { + page: 3, + pages: 9, + sheet: "Data".into(), + file: "b.xlsx".into(), + date: "1/2/2026".into(), + time: "9:00 AM".into(), + ..Default::default() + }; + let [l, c, r] = expand_header_footer("&L&\"Arial,Bold\"&A &D&C&14&BTitle && more&RP&P+1/&N &T", &cx); + assert_eq!(l.text, "Data 1/2/2026"); + assert!(l.bold); + assert_eq!(c.text, "Title & more"); + assert!(c.bold); + assert_eq!(c.size, Some(14.0)); + assert_eq!(r.text, "P4/9 9:00 AM"); + assert_eq!(expand_header_footer("plain", &cx)[1].text, "plain"); + assert_eq!(expand_header_footer("&", &cx)[1].text, ""); +} + +#[test] +fn conditional_number_format_prints() { + // #1163: a rule's number format replaces the cell's own, whether that is General or not; + // cells the rule doesn't match keep theirs. + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1], [3], [4]]})).unwrap(); + s.execute("home.numberFormat", json!({"range": "A3", "code": "0.0"})).unwrap(); + let rule = json!({"type": "cellIs", "operator": "greater", "value": "2", "style": {"num_fmt": "0.00"}}); + s.execute("home.conditionalFormat", json!({"range": "A1:A3", "rule": rule})).unwrap(); + let (pdf, _) = pdf_of(&mut s, json!({})); + assert!(contains(&pdf, "(1) Tj"), "an unmatched cell keeps General"); + assert!(contains(&pdf, "(3.00) Tj") && !contains(&pdf, "(3) Tj"), "General cell under the rule"); + assert!(contains(&pdf, "(4.00) Tj") && !contains(&pdf, "(4.0) Tj"), "formatted cell under the rule"); +} + +#[test] +fn pdf_uses_indexed_number_format_palette_color() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "42"})).unwrap(); + s.execute("home.numberFormat", json!({"range": "A1", "format": "[Color4]0"})).unwrap(); + let (pdf, pages) = pdf_of(&mut s, json!({})); + assert_eq!(pages, 1); + assert!(contains(&pdf, "0 1 0 rg"), "expected indexed Color4 green in PDF text stream"); +} + +#[test] +fn export_writes_file() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "B2", "input": "hello"})).unwrap(); + let dir = std::env::temp_dir().join(format!("gridcraft-print-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("out.pdf"); + let r = s.execute("file.exportPdf", json!({"path": path.to_str().unwrap()})).unwrap(); + assert_eq!(r["pages"], 1); + let bytes = std::fs::read(&path).unwrap(); + assert!(bytes.starts_with(b"%PDF-")); + assert!(contains(&bytes, "(hello) Tj")); + let _ = std::fs::remove_dir_all(&dir); + let r = s.execute("file.print", json!({})).unwrap(); + assert!(r["base64"].as_str().unwrap().len() > 100); +} + +#[test] +fn save_as_pdf_writes_a_pdf_and_keeps_the_workbook_file() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "B2", "input": "hello"})).unwrap(); + let dir = std::env::temp_dir().join(format!("gridcraft-save-pdf-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Out.PDF"); + let r = s.execute("file.saveAs", json!({"path": path.to_str().unwrap()})).unwrap(); + assert_eq!(r["pages"], 1); + let bytes = std::fs::read(&path).unwrap(); + assert!(bytes.starts_with(b"%PDF-")); + assert!(contains(&bytes, "(hello) Tj")); + // The PDF is a print, not the workbook's file: Save still asks where to put the workbook. + let d = s.doc().unwrap(); + assert!(d.path.is_none() && d.is_dirty()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn rich_sheet_renders() { + let mut s = Session::new(); + s.execute("file.new", json!({"sample": "sales"})).unwrap(); + s.execute("range.setValues", json!({"range": "H1", "values": [["A long piece of text that overflows into neighbours"]]})).unwrap(); + s.execute("cell.set", json!({"cell": "H3", "input": "Wrapped text across several lines in a narrow cell"})).unwrap(); + s.execute("home.wrapText", json!({"range": "H3"})).unwrap_or_default(); + s.execute("selection.set", json!({"range": "J5:K6"})).unwrap(); + s.execute("home.mergeCenter", json!({})).unwrap(); + s.execute("cell.set", json!({"cell": "J5", "input": "Merged"})).unwrap(); + s.execute("home.borders", json!({"range": "J5:K6", "preset": "outside", "style": "thick"})).unwrap_or_default(); + let (pdf, pages) = pdf_of(&mut s, json!({"sheets": "all"})); + assert!(pages >= 1); + assert!(contains(&pdf, "(Merged) Tj")); + assert!(contains(&pdf, "(A long piece of text that overflows into neighbours) Tj")); +} + +#[test] +fn charts_and_images_are_drawn() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Fruit", "Qty"], ["Apples", 3], ["Pears", 5]]})).unwrap(); + s.execute("insert.chart", json!({"range": "A1:B3", "type": "pie"})).unwrap_or_default(); + s.execute("insert.chart", json!({"range": "A1:B3"})).unwrap_or_default(); + // A 2×2 PNG with transparency. + let mut png = Vec::new(); + let img = image::RgbaImage::from_raw(2, 2, vec![255, 0, 0, 255, 0, 255, 0, 128, 0, 0, 255, 255, 0, 0, 0, 0]).unwrap(); + image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let d = s.doc_mut().unwrap(); + let wb = std::sync::Arc::make_mut(&mut d.wb); + let id = wb.next_object_id(); + wb.sheet_mut(0).unwrap().images.push(Image { + id, + anchor: Anchor { cell: CellRef::new(5, 5), dx: 0.0, dy: 0.0, width: 40.0, height: 40.0, mode: gridcraft_model::AnchorMode::MoveAndSize }, + data: png.into(), + mime: "image/png".into(), + alt: String::new(), + }); + let charts = s.doc().unwrap().wb.active().unwrap().charts.len(); + let (pdf, _) = pdf_of(&mut s, json!({})); + assert!(contains(&pdf, "/Subtype /Image")); + assert!(contains(&pdf, "/SMask")); + if charts > 0 { + assert!(contains(&pdf, "(Apples) Tj") || contains(&pdf, "(Qty) Tj")); + } +} + +#[test] +fn page_setup_edits_settings() { + let mut s = s(); + let r = s + .execute( + "pageLayout.pageSetup", + json!({"paper": "A4", "orientation": "landscape", "margins": {"left": 1, "top": 0.5}, "right": 0.25, "centerH": true, "printArea": "A1:D20", "titleRows": "1:2", "titleCols": "A:A", "colBreaks": ["C"]}), + ) + .unwrap(); + assert_eq!(r["paper"], "A4"); + assert_eq!(r["orientation"], "Landscape"); + assert_eq!(r["margins"][0], 1.0); + assert_eq!(r["margins"][1], 0.25); + assert_eq!(r["margins"][2], 0.5); + assert_eq!(r["center_h"], true); + assert_eq!(r["title_rows"], json!([0, 1])); + assert_eq!(r["col_breaks"], json!([2])); + let sh = s.doc().unwrap().wb.active().unwrap().print.clone(); + assert_eq!(sh.print_area, RangeRef::parse("A1:D20")); + s.execute("pageLayout.pageSetup", json!({"printArea": ""})).unwrap(); + assert_eq!(s.doc().unwrap().wb.active().unwrap().print.print_area, None); + assert!(s.execute("pageLayout.pageSetup", json!({"paper": "Napkin"})).is_err()); + assert!(s.execute("pageLayout.pageSetup", json!({"orientation": "sideways"})).is_err()); + // Undoable as one step. + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.doc().unwrap().wb.active().unwrap().print.print_area.is_some()); +} + +/// The Japanese paper sizes are the JIS ones: `B4`/`B5`/`B6` changed meaning from ISO, and the +/// ISO B series stays reachable by name. The postcards and envelopes are the JIS sizes too. +#[test] +fn japanese_paper_sizes_are_jis() { + for (name, w, h) in [ + ("A4", 595.28, 841.89), // unchanged + ("B4", 728.50, 1031.81), + ("B5", 515.91, 728.50), + ("B6", 362.83, 515.91), + ("B4 (JIS)", 728.50, 1031.81), + ("b5-jis", 515.91, 728.50), + ("B4 (ISO)", 708.66, 1000.63), + ("B5 (ISO)", 498.90, 708.66), + ("B6 (ISO)", 354.33, 498.90), + ("A6", 297.64, 419.53), + ("Japanese Postcard", 283.46, 419.53), + ("japanese double postcard", 419.53, 566.93), + ("Japanese Envelope #3", 340.16, 666.14), + ("chou #4", 255.12, 581.10), + ("Japanese Envelope Kaku #2", 680.31, 941.10), + ("kaku #3", 612.28, 785.20), + ] { + let got = paper_size(name); + assert!((got.0 - w).abs() < 0.01 && (got.1 - h).abs() < 0.01, "{name}: {got:?}"); + } + // 長形4号 is 90×205 mm (90×225 mm is 長形40号), so its height is 205 mm = 581.10 pt — not the + // 225 mm it once had. + let chou4 = paper_size("Japanese Envelope #4"); + assert_eq!((chou4.0, chou4.1), (255.12, 581.10)); + assert_eq!(chou4, paper_size("chou #4")); + // Unknown names keep the old Letter fallback. + assert_eq!(paper_size("Napkin"), (612.0, 792.0)); +} + +/// Page Setup's paper reaches the PDF page: the MediaBox is the paper's, A4 is unchanged, B5 is +/// JIS B5, and the Japanese postcard prints at 283.46×419.53 pt. Landscape swaps the sides and +/// fit-to-page keeps the paper size. +#[test] +fn page_setup_paper_sizes_reach_the_pdf_page() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["x"]]})).unwrap(); + let close = |v: &Json, want: f64| (v.as_f64().unwrap() - want).abs() < 0.01; + for (paper, w, h) in [ + ("A4", 595.28f64, 841.89f64), + ("B5", 515.91, 728.50), + ("Japanese Postcard", 283.46, 419.53), + ("Japanese Double Postcard", 419.53, 566.93), + ("Japanese Envelope Kaku #2", 680.31, 941.10), + ] { + s.execute("pageLayout.pageSetup", json!({"paper": paper, "orientation": "portrait"})).unwrap(); + let pv = s.execute("file.printPreview", json!({})).unwrap(); + let page = &pv["pageList"][0]; + assert_eq!(page["paper"], paper); + assert!(close(&page["width"], w) && close(&page["height"], h), "{paper}: {page}"); + let (pdf, pages) = pdf_of(&mut s, json!({})); + assert_eq!(pages, 1); + let want = format!("/MediaBox [0 0 {w} {h}]"); + assert!(contains(&pdf, &want), "{paper}: wanted {want}"); + } + // Landscape swaps the sides, and fit-to-page does not change the paper. + s.execute("pageLayout.pageSetup", json!({"paper": "B5", "orientation": "landscape"})).unwrap(); + let (pdf, pages) = pdf_of(&mut s, json!({})); + assert_eq!(pages, 1); + assert!(contains(&pdf, "/MediaBox [0 0 728.5 515.91]")); + s.execute("pageLayout.pageSetup", json!({"paper": "B5", "orientation": "portrait"})).unwrap(); + let (pdf, _) = pdf_of(&mut s, json!({"fitToPage": true})); + assert!(contains(&pdf, "/MediaBox [0 0 515.91 728.5]")); + // `pageLayout.size` still takes any name, and an unknown one prints on Letter as before. + s.execute("pageLayout.size", json!({"paper": "Napkin"})).unwrap(); + let pv = s.execute("file.printPreview", json!({})).unwrap(); + assert!(close(&pv["pageList"][0]["width"], 612.0) && close(&pv["pageList"][0]["height"], 792.0)); + // Page Setup refuses an unknown name. + assert!(s.execute("pageLayout.pageSetup", json!({"paper": "Napkin"})).is_err()); +} + +#[test] +fn hostile_settings_never_panic_and_cap_pages() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "XFD1048576", "input": "far"})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "input": "near"})).unwrap(); + s.execute("pageLayout.pageSetup", json!({"scale": 400, "margins": {"left": 20, "right": 20, "top": 20, "bottom": 20}})).unwrap(); + let pv = s.execute("file.printPreview", json!({})).unwrap(); + assert!(pv["pages"].as_u64().unwrap() <= MAX_PAGES as u64); + 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); + 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(); + assert!(pv["pages"].as_u64().unwrap() <= MAX_PAGES as u64); + assert!(s.execute("file.exportPdf", json!({"range": "A1:C3"})).is_ok()); + assert!(s.execute("file.exportPdf", json!({"range": "nope"})).is_err()); + assert!(s.execute("file.exportPdf", json!({"sheets": "some"})).is_err()); + // Empty workbook still prints one blank page. + let mut e = Session::new(); + e.new_workbook(); + let r = e.execute("file.exportPdf", json!({})).unwrap(); + assert_eq!(r["pages"], 1); + let r = e.execute("file.exportPdf", json!({"sheets": "all"})).unwrap(); + assert_eq!(r["pages"], 1); + // Weird row sizes and huge fonts. + s.execute("pageLayout.pageSetup", json!({"fitToPage": false, "scale": 10, "printArea": "A1:B3", "titleRows": "", "titleCols": ""})).unwrap(); + s.execute("home.fontSize", json!({"range": "A1", "size": 409})).unwrap_or_default(); + assert!(s.execute("file.exportPdf", json!({})).is_ok()); +} + +#[test] +fn export_pdf_without_a_path_returns_bytes_and_never_opens_a_dialog() { + let mut s = s(); + for params in [json!({}), json!({"path": ""}), json!({"path": null})] { + let r = s.execute("file.exportPdf", params).unwrap(); + assert!(r.get("path").is_none(), "no file is written without a path"); + let bytes = crate::io::base64_decode(r["base64"].as_str().unwrap()).unwrap(); + assert!(bytes.starts_with(b"%PDF-")); + } + assert!(s.take_ui_requests().is_empty(), "a programmatic call never queues a dialog"); + // Headless callers (CLI, MCP) get the same bytes, not an error. + assert!(s.execute_headless("file.exportPdf", json!({})).is_ok()); + // With no workbook open it is a clear error, not a panic. + let mut none = Session::new(); + assert!(none.execute("file.exportPdf", json!({})).is_err()); +} + +#[test] +fn bands_unit() { + let b = bands(0, 9, |_| 10.0, |_| 35.0, &[], 100); + assert_eq!(b, vec![(0, 2), (3, 5), (6, 8), (9, 9)]); + let b = bands(0, 9, |_| 10.0, |_| 35.0, &[5], 100); + assert_eq!(b, vec![(0, 2), (3, 4), (5, 7), (8, 9)]); + // A line larger than the page gets its own band. + let b = bands(0, 2, |r| if r == 1 { 100.0 } else { 10.0 }, |_| 35.0, &[], 100); + assert_eq!(b, vec![(0, 0), (1, 1), (2, 2)]); + assert_eq!(bands(0, 1_000_000, |_| 10.0, |_| 5.0, &[], 3).len(), 3); + let w = wrap_lines("the quick brown fox jumps", Font::Helvetica, 10.0, 50.0); + assert!(w.len() >= 2); + assert!(w.iter().all(|l| gridcraft_pdf::text_width(Font::Helvetica, 10.0, l) <= 50.0 || !l.contains(' '))); + let w = wrap_lines("Supercalifragilistic", Font::Helvetica, 10.0, 20.0); + assert!(w.len() > 2); +} + +#[test] +fn center_across_spans_following_empty_center_across_cells() { + let mut styles = StyleTable::default(); + let across = styles.intern(Style { align: Alignment { h: HAlign::CenterAcross, ..Alignment::default() }, ..Style::default() }); + let mut sh = Sheet::new("S"); + for col in 0..3 { + sh.set_style(CellRef::new(0, col), across); + } + sh.set_value(CellRef::new(0, 0), Value::Text("Title".into())); + let a1 = CellRef::new(0, 0); + assert_eq!(center_across_width(&styles, &sh, a1, &[]), sh.col_width(1) + sh.col_width(2)); + sh.set_value(CellRef::new(0, 2), Value::Text("x".into())); + assert_eq!(center_across_width(&styles, &sh, a1, &[]), sh.col_width(1)); + let merged = [RangeRef::new(CellRef::new(0, 1), CellRef::new(1, 1))]; + assert_eq!(center_across_width(&styles, &sh, a1, &merged), 0.0); +} + +#[test] +fn rotated_wrapped_text_prints_rotated() { + // Upright headers over narrow columns (mark sheets) combine rotation with Wrap Text. + let mut s = s(); + let header = "Term 1 : Drama : Continuous Assessment"; + s.execute("range.setValues", json!({"range": "A1", "values": [[header, header]]})).unwrap(); + s.execute("home.wrapText", json!({"range": "A1:B1", "on": true})).unwrap(); + s.execute("home.orientation", json!({"range": "A1", "angle": "up"})).unwrap(); + s.execute("home.orientation", json!({"range": "B1", "angle": "down"})).unwrap(); + s.execute("home.columnWidth", json!({"cols": "A:B", "chars": 6})).unwrap(); + s.execute("home.rowHeight", json!({"rows": "1:1", "height": 267})).unwrap(); + let (pdf, _) = pdf_of(&mut s, json!({})); + let pdf = String::from_utf8_lossy(&pdf); + // The page holds only these two cells, so every text block on it is a piece of a header. + let blocks: Vec<&str> = pdf.split("BT\n").skip(1).filter(|b| b.contains(" Tj")).collect(); + let up = blocks.iter().filter(|b| b.contains("\n0 1 -1 0 ")).count(); + let down = blocks.iter().filter(|b| b.contains("\n0 -1 1 0 ")).count(); + // Every piece of both headers is rotated, each wrapped along the row height into a few long lines. + assert_eq!(up + down, blocks.len(), "a header line printed flat: {blocks:?}"); + assert!((1..=3).contains(&up) && (1..=3).contains(&down), "up {up}, down {down}: {blocks:?}"); +}