//! 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. use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value}; use gridcraft_model::*; use gridcraft_pdf::{Font, Page, PdfDoc, Rgb}; use serde_json::{Value as Json, json}; use super::*; pub fn specs() -> Vec { vec![ cmd!( noundo "file.exportPdf", "Export as PDF", ["File", "Export"], None, "{path?, sheets?: active|all, range?: \"A1:F40\" (active sheet), fitToPage?: bool} → {pages, bytes, path} or {pages, bytes, base64} when no path. Honours each sheet's Page Setup.", has_doc, export_pdf ), cmd!( noundo "file.print", "Print…", ["File"], Some("Cmd+P"), "{path?, sheets?: active|all, range?, fitToPage?: bool} → {pages, bytes, base64} — the PDF the UI hands to the system print dialog", has_doc, print ), cmd!( query "file.printPreview", "Print Preview", ["File"], None, "{page? (1-based), sheets?: active|all, range?, fitToPage?} → {pages, pageList: [{page, sheet, range, rows, columns, scale, paper, orientation, width, height}]}", has_doc, print_preview ), cmd!( "pageLayout.pageSetup", "Page Setup…", ["Page Layout", "Page Setup"], None, "{sheet?, orientation?: portrait|landscape, paper?: Letter|Legal|A4|A3|A5|Tabloid|Executive|Ledger|B5, 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", has_doc, page_setup ), ] } /// Sheet geometry is in pixels at 96 dpi; PDF units are points. pub(crate) const PX_TO_PT: f32 = 0.75; /// Most pages one export produces. pub const MAX_PAGES: usize = 2000; /// Cells in a page block above which only stored cells are visited (not every grid position). const DENSE_BLOCK: u64 = 60_000; // ---------------------------------------------------------------- paper /// Paper size in points (portrait) for a paper name; unknown names fall back to Letter. pub fn paper_size(name: &str) -> (f32, f32) { known_paper(name).unwrap_or((612.0, 792.0)) } fn known_paper(name: &str) -> Option<(f32, f32)> { let n = name.trim().to_ascii_lowercase().replace([' ', '-', '_'], ""); Some(match n.as_str() { "letter" | "usletter" => (612.0, 792.0), "legal" | "uslegal" => (612.0, 1008.0), "a4" => (595.28, 841.89), "a3" => (841.89, 1190.55), "a5" => (419.53, 595.28), "tabloid" | "11x17" => (792.0, 1224.0), "ledger" => (1224.0, 792.0), "executive" => (522.0, 756.0), "b5" => (498.9, 708.66), "b4" => (708.66, 1000.63), _ => return None, }) } // ---------------------------------------------------------------- layout /// The pagination of one sheet. #[derive(Clone, Debug)] pub struct SheetLayout { pub sheet: usize, pub settings: PrintSettings, pub page_w: f32, pub page_h: f32, /// Left, right, top, bottom, header, footer margins in points. pub margins: [f32; 6], pub scale: f32, pub area: RangeRef, pub row_bands: Vec<(u32, u32)>, pub col_bands: Vec<(u32, u32)>, /// Pages in print order: (row band, column band). pub pages: Vec<(usize, usize)>, pub title_rows: Option<(u32, u32)>, pub title_cols: Option<(u32, u32)>, /// Heading sizes in sheet pixels (0 when headings are off). pub heading_w: f32, pub heading_h: f32, /// Manual page breaks apply (they don't when the sheet is fitted to pages). pub manual_breaks: bool, } impl SheetLayout { fn k(&self) -> f32 { PX_TO_PT * self.scale } pub(crate) fn avail(&self) -> (f32, f32) { let [l, r, t, b, ..] = self.margins; ((self.page_w - l - r).max(36.0), (self.page_h - t - b).max(36.0)) } } /// Options shared by the print commands. #[derive(Clone, Debug, Default)] pub struct PrintOptions { pub all_sheets: bool, pub range: Option, pub fit_to_page: Option, } impl PrintOptions { fn from_params(p: &Json, cmd: &str) -> Result { let all_sheets = match str_param(p, "sheets") { None | Some("active") => false, Some("all") | Some("workbook") => true, Some(other) => return Err(bad(cmd, format!("`sheets` must be active or all, not `{other}`"))), }; let range = match str_param(p, "range").map(str::trim).filter(|r| !r.is_empty()) { Some(r) => Some(RangeRef::parse(split_sheet(r).1).ok_or_else(|| bad(cmd, format!("not a range: `{r}`")))?), None => None, }; Ok(PrintOptions { all_sheets, range, fit_to_page: bool_param(p, "fitToPage") }) } } /// The range a sheet prints: its print area, else the used range grown to cover drawing /// objects. `None` when there is nothing to print. pub fn print_range(sh: &Sheet) -> Option { if let Some(a) = sh.print.print_area { return Some(trim_full(sh, a)); } let mut r = sh.used_range(); let mut grow = |a: &Anchor| { let x = sh.col_left(a.cell.col) + a.dx as f64; let y = sh.row_top(a.cell.row) + a.dy as f64; let end = CellRef::new(sh.row_at(y + a.height.max(0.0) as f64), sh.col_at(x + a.width.max(0.0) as f64)); let ar = RangeRef::new(a.cell, end); r = Some(r.map_or(ar, |u| u.union(&ar))); }; for c in &sh.charts { grow(&c.anchor); } for i in &sh.images { grow(&i.anchor); } for s in &sh.shapes { grow(&s.anchor); } // Printing starts at A1 like Excel when the data starts nearby? Excel prints from the first // used cell; keep the used range as is. r } /// Whole rows/columns are trimmed to the used range. fn trim_full(sh: &Sheet, r: RangeRef) -> RangeRef { if !(r.is_full_cols() || r.is_full_rows()) { return r; } match sh.used_range() { Some(u) => r.intersection(&RangeRef::new(CellRef::new(0, 0), u.end)).unwrap_or(RangeRef::cell(r.start)), None => RangeRef::cell(r.start), } } /// Splits `start..=end` into bands whose sizes fit `avail(band_start)`; `breaks` force a new /// band before a line. Stops after `cap` bands. fn bands(start: u32, end: u32, size: impl Fn(u32) -> f32, avail: impl Fn(u32) -> f32, breaks: &[u32], cap: usize) -> Vec<(u32, u32)> { let mut out = Vec::new(); let mut cur = start; let mut used = 0.0f32; let mut room = avail(start); let mut line = start; loop { let sz = size(line); let manual = line > cur && breaks.binary_search(&line).is_ok(); if line > cur && (manual || used + sz > room + 0.01) { out.push((cur, line - 1)); if out.len() >= cap { return out; } cur = line; used = 0.0; room = avail(line); } used += sz; if line >= end { break; } line += 1; } out.push((cur, end)); out } fn title_span(t: Option<(u32, u32)>, max: u32) -> Option<(u32, u32)> { let (a, b) = t?; let (a, b) = (a.min(b), a.max(b)); if a >= max { return None; } // Repeated titles are capped (Excel needs them to fit on a page anyway). Some((a, b.min(max - 1).min(a.saturating_add(499)))) } /// Paginates one sheet. pub fn layout_sheet(wb: &Workbook, si: usize, opts: &PrintOptions, range_override: Option) -> Option { let sh = wb.sheet(si)?; let st = sh.print.clone(); let (mut pw, mut ph) = paper_size(&st.paper); if st.orientation == Orientation::Landscape { std::mem::swap(&mut pw, &mut ph); } let inch = |i: usize| st.margins.get(i).copied().filter(|v| v.is_finite()).unwrap_or(0.5).clamp(0.0, 20.0) * 72.0; let mut margins = [inch(0), inch(1), inch(2), inch(3), inch(4), inch(5)]; // Keep at least an inch of printable space. for (a, b, total) in [(0usize, 1usize, pw), (2, 3, ph)] { let sum = margins[a] + margins[b]; if total - sum < 72.0 { let f = ((total - 72.0).max(0.0)) / sum.max(1.0); margins[a] *= f; margins[b] *= f; } } let area = match range_override { Some(r) => trim_full(sh, r), None => print_range(sh).unwrap_or(RangeRef::cell(CellRef::new(0, 0))), }; let title_rows = title_span(st.title_rows, MAX_ROWS); let title_cols = title_span(st.title_cols, MAX_COLS); let digits = format!("{}", area.end.row + 1).len() as f32; let (heading_w, heading_h) = if st.headings { (10.0 + 7.0 * digits, sh.default_row_height.clamp(10.0, 60.0)) } else { (0.0, 0.0) }; let fit = match opts.fit_to_page { Some(true) => Some((1u16, 1u16)), Some(false) => None, None => st.fit_to.filter(|(w, h)| *w > 0 || *h > 0), }; let mut lay = SheetLayout { sheet: si, settings: st.clone(), page_w: pw, page_h: ph, margins, scale: (st.scale.clamp(10, 400) as f32) / 100.0, area, row_bands: vec![], col_bands: vec![], pages: vec![], title_rows, title_cols, heading_w, heading_h, manual_breaks: fit.is_none(), }; 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)); } } }; match fit { Some((fw, fh)) => { let total_w: f32 = (area.start.col..=area.end.col).map(|c| sh.col_width(c)).sum::() + heading_w; let total_h: f32 = if area.height() > 200_000 { f32::INFINITY } else { (area.start.row..=area.end.row).map(|r| sh.row_height(r)).sum::() + heading_h }; let (aw, ah) = lay.avail(); let sw = if fw > 0 { aw * fw as f32 / (total_w * PX_TO_PT).max(1.0) } else { f32::INFINITY }; let sh_ = if fh > 0 { ah * fh as f32 / (total_h * PX_TO_PT).max(1.0) } else { f32::INFINITY }; lay.scale = sw.min(sh_).min(1.0).clamp(0.1, 1.0); for _ in 0..40 { paginate(&mut lay, false); let ok_w = fw == 0 || lay.col_bands.len() <= fw as usize; let ok_h = fh == 0 || lay.row_bands.len() <= fh as usize; if (ok_w && ok_h) || lay.scale <= 0.1 { break; } lay.scale = (lay.scale * 0.97).max(0.1); } } None => paginate(&mut lay, true), } Some(lay) } /// Splits a layout's area into row and column bands that fit its pages (at most `cap` of each), /// honouring manual breaks (when `lay.manual_breaks`), print headings and repeated titles. The /// one pagination rule shared by printing, PDF export, Page Break Preview and Page Layout view. pub(crate) fn split_bands(sh: &Sheet, lay: &SheetLayout, cap: usize) -> (Vec<(u32, u32)>, Vec<(u32, u32)>) { let k = lay.k(); let (aw, ah) = lay.avail(); let (aw, ah) = (aw / k, ah / k); let st = &lay.settings; let mut rb: Vec = if lay.manual_breaks { st.row_breaks.clone() } else { vec![] }; let mut cb: Vec = if lay.manual_breaks { st.col_breaks.clone() } else { vec![] }; rb.sort_unstable(); cb.sort_unstable(); let (title_rows, title_cols) = (lay.title_rows, lay.title_cols); let title_h = |start: u32| -> f32 { match title_rows { Some((a, b)) if a < start => (a..=b.min(start - 1)).map(|r| sh.row_height(r)).sum(), _ => 0.0, } }; let title_w = |start: u32| -> f32 { match title_cols { Some((a, b)) if a < start => (a..=b.min(start - 1)).map(|c| sh.col_width(c)).sum(), _ => 0.0, } }; let area = lay.area; let rows = bands(area.start.row, area.end.row, |r| sh.row_height(r), |start| (ah - lay.heading_h - title_h(start)).max(1.0), &rb, cap); let cols = bands(area.start.col, area.end.col, |c| sh.col_width(c), |start| (aw - lay.heading_w - title_w(start)).max(1.0), &cb, cap); (rows, cols) } /// Lays out every sheet a print command covers. pub fn layouts(wb: &Workbook, opts: &PrintOptions) -> Vec { let mut out = Vec::new(); let mut pages = 0usize; let sheets: Vec = if opts.all_sheets { (0..wb.sheets.len()).filter(|i| wb.sheet(*i).is_some_and(|s| s.visibility == Visibility::Visible)).collect() } else { vec![wb.active_sheet] }; for si in sheets { if pages >= MAX_PAGES { break; } let ovr = if si == wb.active_sheet { opts.range } else { None }; if opts.all_sheets && ovr.is_none() && wb.sheet(si).is_some_and(|s| print_range(s).is_none()) { continue; } if let Some(mut l) = layout_sheet(wb, si, opts, ovr) { l.pages.truncate(MAX_PAGES - pages); pages += l.pages.len(); out.push(l); } } if out.is_empty() && opts.all_sheets { out.extend(layout_sheet(wb, wb.active_sheet, opts, None)); } out } // ---------------------------------------------------------------- header / footer /// Values for header/footer codes. #[derive(Clone, Debug, Default)] pub struct HfContext { pub page: usize, pub pages: usize, pub sheet: String, pub file: String, pub path: String, pub date: String, pub time: String, } /// Today's date and the current time as headers and footers show them (`&D`, `&T`). pub fn hf_date_time(wb: &Workbook) -> (String, String) { let now = gridcraft_calc::now_serial(); let date = crate::display::format(&Value::Number(now.floor()), "m/d/yyyy", wb).text; let time = crate::display::format(&Value::Number(now), "h:mm AM/PM", wb).text; (date, time) } /// Header/footer codes and the field names the Page Layout view shows for them while editing. pub const HF_FIELDS: [(char, &str); 8] = [ ('P', "&[Page]"), ('N', "&[Pages]"), ('D', "&[Date]"), ('T', "&[Time]"), ('Z', "&[Path]"), ('F', "&[File]"), ('A', "&[Tab]"), ('G', "&[Picture]"), ]; /// Splits a header/footer string into its raw left, centre and right sections (codes kept). /// Text before the first `&L`/`&C`/`&R` belongs to the centre, as when printing. pub fn split_header_footer(spec: &str) -> [String; 3] { let mut out: [String; 3] = Default::default(); let mut cur = 1usize; let chars: Vec = spec.chars().take(10_000).collect(); let mut i = 0; while let Some(&c) = chars.get(i) { let next = chars.get(i + 1).copied(); let sec = cur.min(2); match (c, next) { ('&', Some('L' | 'l')) => cur = 0, ('&', Some('C' | 'c')) => cur = 1, ('&', Some('R' | 'r')) => cur = 2, ('&', Some('"')) => { // &"Font,Style" is kept whole. let end = chars[i + 2..].iter().position(|ch| *ch == '"').map_or(chars.len(), |p| i + 3 + p); out[sec].extend(&chars[i..end.min(chars.len())]); i = end; continue; } ('&', Some(n)) => { out[sec].push('&'); out[sec].push(n); } ('&', None) => {} (ch, _) => { out[sec].push(ch); i += 1; continue; } } i += 2; } out } /// Joins raw left, centre and right sections into one header/footer string. pub fn join_header_footer(sections: &[String; 3]) -> String { let mut s = String::new(); for (code, text) in ["&L", "&C", "&R"].iter().zip(sections) { if !text.is_empty() { s.push_str(code); s.push_str(text); } } s } /// A raw section as the user edits it: `&P` shows as `&[Page]`, `&N` as `&[Pages]`, and so on. pub fn codes_to_fields(raw: &str) -> String { let mut out = String::new(); let mut it = raw.chars().peekable(); while let Some(c) = it.next() { if c != '&' { out.push(c); continue; } // A dangling `&` at the end is dropped (printing ignores it too). if let Some(n) = it.next() { match HF_FIELDS.iter().find(|(code, _)| *code == n.to_ascii_uppercase()) { Some((_, field)) => out.push_str(field), None => { out.push('&'); out.push(n); } } } } out } /// The inverse of [`codes_to_fields`]: field names become codes again; a dangling `&` at the end /// becomes a literal ampersand so it can't swallow the next section's code. pub fn fields_to_codes(text: &str) -> String { let chars: Vec = text.chars().take(10_000).collect(); let mut out = String::new(); let mut i = 0; while let Some(&c) = chars.get(i) { if c != '&' { out.push(c); i += 1; continue; } if chars.get(i + 1) == Some(&'&') { out.push_str("&&"); i += 2; continue; } let rest: String = chars[i..].iter().take(12).collect(); if let Some((code, field)) = HF_FIELDS.iter().find(|(_, f)| rest.starts_with(f)) { out.push('&'); out.push(*code); i += field.chars().count(); continue; } if i + 1 == chars.len() { out.push_str("&&"); } else { out.push('&'); } i += 1; } out } /// One header/footer section: its text and whether `&B` made it bold. #[derive(Clone, Debug, Default, PartialEq)] pub struct HfSection { pub text: String, pub bold: bool, pub italic: bool, pub size: Option, } /// Expands a header/footer string into its left, centre and right sections. pub fn expand_header_footer(spec: &str, cx: &HfContext) -> [HfSection; 3] { let mut out: [HfSection; 3] = Default::default(); let mut cur = 1usize; let chars: Vec = spec.chars().take(10_000).collect(); let mut i = 0; while i < chars.len() { let c = chars[i]; if c != '&' { if let Some(s) = out.get_mut(cur) { s.text.push(c); } i += 1; continue; } let Some(&code) = chars.get(i + 1) else { break }; i += 2; let sec = &mut out[cur.min(2)]; match code { 'L' | 'l' => cur = 0, 'C' | 'c' => cur = 1, 'R' | 'r' => cur = 2, 'P' | 'p' => { // &P, &P+n, &P-n let mut n = cx.page as i64; if let Some(&sign) = chars.get(i) && (sign == '+' || sign == '-') { let digits: String = chars[i + 1..].iter().take_while(|d| d.is_ascii_digit()).take(6).collect(); if let Ok(d) = digits.parse::() { n = if sign == '+' { n + d } else { n - d }; i += 1 + digits.len(); } } sec.text.push_str(&n.to_string()); } 'N' | 'n' => sec.text.push_str(&cx.pages.to_string()), 'D' | 'd' => sec.text.push_str(&cx.date), 'T' | 't' => sec.text.push_str(&cx.time), 'A' | 'a' => sec.text.push_str(&cx.sheet), 'F' | 'f' => sec.text.push_str(&cx.file), 'Z' | 'z' => sec.text.push_str(&cx.path), '&' => sec.text.push('&'), 'B' | 'b' => sec.bold = !sec.bold, 'I' | 'i' => sec.italic = !sec.italic, '"' => { // &"Font,Style": skip the font name; honour Bold/Italic in the style. let rest: String = chars[i..].iter().take_while(|c| **c != '"').collect(); let lower = rest.to_ascii_lowercase(); if lower.contains("bold") { sec.bold = true; } if lower.contains("italic") || lower.contains("oblique") { sec.italic = true; } i += rest.chars().count() + 1; } d if d.is_ascii_digit() => { let digits: String = std::iter::once(d).chain(chars[i..].iter().copied().take_while(|c| c.is_ascii_digit())).take(3).collect(); i += digits.len() - 1; if let Ok(v) = digits.parse::() { sec.size = Some(v.clamp(1.0, 409.0)); } } // Underline, strike, super/subscript, colour, picture: not rendered. 'K' | 'k' => { i = (i + 6).min(chars.len()); } _ => {} } } out } // ---------------------------------------------------------------- drawing fn rgb_of(c: Color, wb: &Workbook) -> Option { c.resolve(&wb.theme) } fn numfmt_rgb(c: gridcraft_numfmt::FormatColor) -> Rgb { use gridcraft_numfmt::FormatColor as F; match c { F::Black | F::Indexed(_) => [0, 0, 0], F::Blue => [0, 0, 255], F::Cyan => [0, 255, 255], F::Green => [0, 128, 0], F::Magenta => [255, 0, 255], F::Red => [255, 0, 0], F::White => [255, 255, 255], F::Yellow => [255, 255, 0], } } fn blend(fg: [u8; 4]) -> Rgb { let a = fg[3] as u16; let mix = |c: u8| ((c as u16 * a + 255 * (255 - a)) / 255) as u8; [mix(fg[0]), mix(fg[1]), mix(fg[2])] } /// The effective look of a cell: own style, table style, conditional formats. struct Look { style: Style, fill: Option, cf: Option, } fn cell_look(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, cf: &mut Option) -> Look { let mut style = match sh.table_at(c) { Some(t) => crate::tables::table_cell_style(wb, si, t, c), None => wb.styles.get(sh.style_id(c)).clone(), }; let cfl = cf.as_mut().and_then(|cache| cache.look(wb, si, c)); if let Some(st) = cfl.as_ref().and_then(|l| l.style.as_ref()) { if st.fill.pattern != PatternType::None { style.fill = st.fill; } if st.font.color != Color::Auto { style.font.color = st.font.color; } style.font.bold |= st.font.bold; style.font.italic |= st.font.italic; style.font.strike |= st.font.strike; if st.font.underline != Underline::None { style.font.underline = st.font.underline; } for (dst, src) in [ (&mut style.borders.left, st.borders.left), (&mut style.borders.right, st.borders.right), (&mut style.borders.top, st.borders.top), (&mut style.borders.bottom, st.borders.bottom), ] { if !src.is_none() { *dst = src; } } } let mut fill = match style.fill.pattern { PatternType::None => None, PatternType::Solid => rgb_of(style.fill.fg, wb), _ => { let fg = rgb_of(style.fill.fg, wb).unwrap_or([0, 0, 0]); let bg = rgb_of(style.fill.bg, wb).unwrap_or([255, 255, 255]); Some([0, 1, 2].map(|i| ((fg[i] as u16 + bg[i] as u16 * 3) / 4) as u8)) } }; if let Some(rgb) = cfl.as_ref().and_then(|l| l.scale_fill) { fill = Some(rgb); } Look { style, fill, cf: cfl } } /// PDF measurement for chart layout. struct PdfMeasure; impl gridcraft_chart::Measure for PdfMeasure { fn text_width(&self, text: &str, size: f32, bold: bool) -> f32 { gridcraft_pdf::text_width(if bold { Font::HelveticaBold } else { Font::Helvetica }, size, text) } } /// Maps chart-space coordinates to the page. #[derive(Clone, Copy)] struct Xf { ox: f32, oy: f32, k: f32, } impl Xf { fn p(&self, x: f32, y: f32) -> (f32, f32) { (self.ox + x * self.k, self.oy + y * self.k) } fn pts(&self, pts: &[[f32; 2]]) -> Vec<[f32; 2]> { pts.iter().map(|q| [self.ox + q[0] * self.k, self.oy + q[1] * self.k]).collect() } } /// Draws chart primitives onto a page. fn draw_prims(pg: &mut Page, prims: &[gridcraft_chart::Prim], xf: Xf) { use gridcraft_chart::{HAlign as H, Prim, VAlign as V}; let k = xf.k; for p in prims { match p { Prim::Rect { x, y, w, h, fill, stroke, .. } => { let (px, py) = xf.p(*x, *y); if let Some(f) = fill { pg.fill_rect(px, py, w * k, h * k, blend(*f)); } if let Some((c, lw)) = stroke { pg.rect_stroke(px, py, w * k, h * k, (lw * k).max(0.1), blend(*c)); } } Prim::Line { pts, color, width, dash } => { let d = if *dash { vec![3.0 * k, 2.0 * k] } else { vec![] }; pg.polyline(&xf.pts(pts), (width * k).max(0.1), blend(*color), &d); } Prim::Polygon { pts, fill, stroke } => { pg.polygon(&xf.pts(pts), Some(blend(*fill)), stroke.map(|(c, w)| (blend(c), (w * k).max(0.1)))); } Prim::Wedge { cx, cy, r_outer, r_inner, a0, a1, fill, stroke } => { let span = (a1 - a0).abs().min(std::f32::consts::TAU); let n = ((span / (std::f32::consts::PI / 36.0)).ceil() as usize).clamp(2, 400); let at = |a: f32, r: f32| [cx + r * a.sin(), cy - r * a.cos()]; let mut pts: Vec<[f32; 2]> = (0..=n).map(|i| at(a0 + (a1 - a0) * i as f32 / n as f32, *r_outer)).collect(); if *r_inner > 0.0 { pts.extend((0..=n).rev().map(|i| at(a0 + (a1 - a0) * i as f32 / n as f32, *r_inner))); } else { pts.push([*cx, *cy]); } pg.polygon(&xf.pts(&pts), Some(blend(*fill)), stroke.map(|(c, w)| (blend(c), (w * k).max(0.1)))); } Prim::Circle { cx, cy, r, fill, stroke } => { let (px, py) = xf.p(*cx, *cy); pg.circle(px, py, r * k, fill.map(blend), stroke.map(|(c, w)| (blend(c), (w * k).max(0.1)))); } Prim::Text { x, y, text, size, color, bold, align, valign, rotation } => { let font = if *bold { Font::HelveticaBold } else { Font::Helvetica }; let size = size * k; let w = gridcraft_pdf::text_width(font, size, text); let dx = match align { H::Left => 0.0, H::Center => -w / 2.0, H::Right => -w, }; let dy = match valign { V::Top => size * 0.78, V::Middle => size * 0.35, V::Bottom => -size * 0.21, }; let (sn, cs) = if rotation.is_finite() { rotation.sin_cos() } else { (0.0, 1.0) }; let (ax, ay) = xf.p(*x, *y); pg.text_rotated(ax + dx * cs - dy * sn, ay + dx * sn + dy * cs, size, font, blend(*color), text, *rotation); } } } } const PAD_PX: f32 = 2.0; /// Word-wraps `text` to `width` points. fn wrap_lines(text: &str, font: Font, size: f32, width: f32) -> Vec { let mut out = Vec::new(); for para in text.split('\n') { let mut line = String::new(); for word in para.split(' ') { let cand = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; if line.is_empty() || gridcraft_pdf::text_width(font, size, &cand) <= width { line = cand; } else { out.push(std::mem::take(&mut line)); line = word.to_string(); } // Break a single over-long word by characters. while gridcraft_pdf::text_width(font, size, &line) > width && line.chars().count() > 1 { let mut cut = String::new(); let mut rest = String::new(); for ch in line.chars() { if rest.is_empty() && gridcraft_pdf::text_width(font, size, &format!("{cut}{ch}")) <= width || cut.is_empty() { cut.push(ch); } else { rest.push(ch); } } if rest.is_empty() { break; } out.push(cut); line = rest; } if out.len() > 2000 { return out; } } out.push(line); } out } /// Everything needed to draw one page block (a run of rows × a run of columns). struct Block<'a> { wb: &'a Workbook, si: usize, sh: &'a Sheet, rows: (u32, u32), cols: (u32, u32), /// Page x of each column's left edge, plus the right edge of the last. xs: Vec, ys: Vec, k: f32, scale: f32, } impl Block<'_> { fn x(&self, col: u32) -> f32 { if col >= self.cols.0 && col <= self.cols.1 + 1 { return self.xs.get((col - self.cols.0) as usize).copied().unwrap_or(0.0); } let x0 = self.xs.first().copied().unwrap_or(0.0); x0 + ((self.sh.col_left(col) - self.sh.col_left(self.cols.0)) as f32) * self.k } fn y(&self, row: u32) -> f32 { if row >= self.rows.0 && row <= self.rows.1 + 1 { return self.ys.get((row - self.rows.0) as usize).copied().unwrap_or(0.0); } let y0 = self.ys.first().copied().unwrap_or(0.0); y0 + ((self.sh.row_top(row) - self.sh.row_top(self.rows.0)) as f32) * self.k } fn rect(&self, r: RangeRef) -> (f32, f32, f32, f32) { let (x0, y0) = (self.x(r.start.col), self.y(r.start.row)); (x0, y0, self.x(r.end.col.saturating_add(1)) - x0, self.y(r.end.row.saturating_add(1)) - y0) } fn range(&self) -> RangeRef { RangeRef::new(CellRef::new(self.rows.0, self.cols.0), CellRef::new(self.rows.1, self.cols.1)) } } fn border_dash(style: BorderStyle, k: f32) -> Vec { let u = k.max(0.2); match style { BorderStyle::Dashed | BorderStyle::MediumDashed => vec![4.0 * u, 2.0 * u], BorderStyle::Dotted => vec![1.0 * u, 1.0 * u], BorderStyle::Hair => vec![0.75 * u, 0.75 * u], BorderStyle::DashDot | BorderStyle::MediumDashDot | BorderStyle::SlantDashDot => vec![4.0 * u, 1.5 * u, 1.0 * u, 1.5 * u], BorderStyle::DashDotDot | BorderStyle::MediumDashDotDot => vec![4.0 * u, 1.5 * u, 1.0 * u, 1.5 * u, 1.0 * u, 1.5 * u], _ => vec![], } } fn draw_border(pg: &mut Page, b: &BorderLine, wb: &Workbook, x0: f32, y0: f32, x1: f32, y1: f32, k: f32) { if b.is_none() { return; } let color = rgb_of(b.color, wb).unwrap_or([0, 0, 0]); if b.style == BorderStyle::Double { let off = 1.0 * k; let lw = (0.75 * k).max(0.2); let (dx, dy) = if (y0 - y1).abs() < 0.001 { (0.0, off) } else { (off, 0.0) }; pg.stroke_line(x0 - dx, y0 - dy, x1 - dx, y1 - dy, lw, color); pg.stroke_line(x0 + dx, y0 + dy, x1 + dx, y1 + dy, lw, color); return; } let lw = (b.style.width() * k).clamp(0.25, 6.0); pg.stroke_line_dashed(x0, y0, x1, y1, lw, color, &border_dash(b.style, k)); } fn font_for(style: &Style) -> Font { Font::styled(Font::family_for(&style.font.name), style.font.bold, style.font.italic) } /// Draws the cells of a block: fills, gridlines, data bars, borders and text. fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Option) { let (bx, by) = (b.xs.first().copied().unwrap_or(0.0), b.ys.first().copied().unwrap_or(0.0)); let (bw, bh) = (b.xs.last().copied().unwrap_or(bx) - bx, b.ys.last().copied().unwrap_or(by) - by); if bw <= 0.0 || bh <= 0.0 { return; } let block = b.range(); let sh = b.sh; let merges: Vec = sh.merges.iter().filter(|m| m.intersects(&block)).copied().collect(); let covered = |c: CellRef| merges.iter().any(|m| m.contains(c)); // Cells worth visiting. let cells: Vec = if block.count() <= DENSE_BLOCK { block.iter().filter(|c| !covered(*c)).collect() } else { let mut set: Vec = sh.cells.iter_range(block).map(|(c, _)| c).collect(); set.extend(sh.spill.range(block.start..=block.end).map(|(c, _)| *c).filter(|c| block.contains(*c))); set.sort_unstable(); set.dedup(); set.retain(|c| !covered(*c)); set }; pg.save(); pg.clip_rect(bx, by, bw, bh); if gridlines { let grey = [0xB0, 0xB0, 0xB0]; let lw = (0.35 * b.scale).clamp(0.1, 0.5); for x in &b.xs { pg.stroke_line(*x, by, *x, by + bh, lw, grey); } for y in &b.ys { pg.stroke_line(bx, *y, bx + bw, *y, lw, grey); } } // Cheap pre-filter: blank, unstyled cells outside tables and conditional formats draw nothing. let skip = |c: &CellRef| { sh.cond_formats.is_empty() && sh.value_ref(*c).is_none_or(|v| v.is_empty()) && sh.style_id(*c) == StyleId::DEFAULT && (sh.tables.is_empty() || sh.table_at(*c).is_none()) }; let looks: Vec<(CellRef, Look)> = cells .iter() .filter(|c| !skip(c)) .chain(merges.iter().map(|m| &m.start)) .map(|c| (*c, cell_look(b.wb, b.si, sh, *c, cf))) .filter(|(c, l)| { l.fill.is_some() || l.cf.is_some() || merges.iter().any(|m| m.start == *c) || !sh.value(*c).is_empty() || l.style.borders != Borders::default() }) .collect(); let rect_of = |c: CellRef| -> (f32, f32, f32, f32, bool) { match merges.iter().find(|m| m.start == c) { Some(m) => { let (x, y, w, h) = b.rect(*m); (x, y, w, h, true) } None => { let (x, y, w, h) = b.rect(RangeRef::cell(c)); (x, y, w, h, false) } } }; // Fills (merged blocks hide the gridlines inside them). for (c, l) in &looks { let (x, y, w, h, merged) = rect_of(*c); if w <= 0.0 || h <= 0.0 { continue; } if let Some(f) = l.fill { pg.fill_rect(x, y, w, h, f); } else if merged && gridlines { pg.fill_rect(x, y, w, h, [255, 255, 255]); } if merged && gridlines { pg.rect_stroke(x, y, w, h, (0.35 * b.scale).clamp(0.1, 0.5), [0xB0, 0xB0, 0xB0]); } if let Some((frac, rgb, _)) = l.cf.as_ref().and_then(|cf| cf.bar) { let pad = 1.5 * b.k; let bwid = ((w - 2.0 * pad) * frac.clamp(0.0, 1.0)).max(0.0); pg.fill_rect(x + pad, y + pad, bwid, (h - 2.0 * pad).max(0.0), rgb); } } // Borders. for (c, l) in &looks { let (x, y, w, h, merged) = rect_of(*c); if w <= 0.0 || h <= 0.0 { continue; } let bd = &l.style.borders; let (right, bottom) = match merges.iter().find(|m| m.start == *c).filter(|_| merged) { Some(m) => ( b.wb.styles.get(sh.style_id(CellRef::new(m.start.row, m.end.col))).borders.right, b.wb.styles.get(sh.style_id(CellRef::new(m.end.row, m.start.col))).borders.bottom, ), None => (bd.right, bd.bottom), }; draw_border(pg, &bd.top, b.wb, x, y, x + w, y, b.k); draw_border(pg, &bottom, b.wb, x, y + h, x + w, y + h, b.k); draw_border(pg, &bd.left, b.wb, x, y, x, y + h, b.k); draw_border(pg, &right, b.wb, x + w, y, x + w, y + h, b.k); draw_border(pg, &bd.diag_down, b.wb, x, y, x + w, y + h, b.k); draw_border(pg, &bd.diag_up, b.wb, x, y + h, x + w, y, b.k); } // Text. for (c, l) in &looks { let (x, y, w, h, merged) = rect_of(*c); if w <= 0.0 || h <= 0.0 { continue; } if let Some(picture) = sh.cell_pictures.get(c) { draw_cell_picture(pg, &picture.data, (x, y, w, h), b.k); } else { draw_text(pg, b, *c, l, (x, y, w, h), merged, &merges, (bx, bx + bw)); } } pg.restore(); } /// Cell pictures fit the current cell (or merged-cell) rectangle without changing aspect ratio. fn draw_cell_picture(pg: &mut Page, data: &[u8], rect: (f32, f32, f32, f32), k: f32) { let (x, y, w, h) = rect; let pad = 2.0 * k; let (w, h) = ((w - 2.0 * pad).max(0.0), (h - 2.0 * pad).max(0.0)); if w <= 0.0 || h <= 0.0 || data.len() > 16 * 1024 * 1024 { return; } let Some((iw, ih)) = crate::io::image_size(data) else { return }; if iw == 0 || ih == 0 || iw > 8192 || ih > 8192 || u64::from(iw) * u64::from(ih) > 16_000_000 { return; } let Ok(mut reader) = image::ImageReader::new(std::io::Cursor::new(data)).with_guessed_format() else { return }; let mut limits = image::Limits::default(); limits.max_image_width = Some(8192); limits.max_image_height = Some(8192); limits.max_alloc = Some(64 * 1024 * 1024); reader.limits(limits); let Ok(image) = reader.decode() else { return }; let image = image.thumbnail(((w / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32, ((h / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32).to_rgba8(); let fit = (w / iw as f32).min(h / ih as f32); let (pw, ph) = (iw as f32 * fit, ih as f32 * fit); pg.image_rgba(x + pad + (w - pw) / 2.0, y + pad + (h - ph) / 2.0, pw, ph, image.as_raw(), image.width(), image.height()); } /// Is a cell free for text to overflow into? fn empty_for_overflow(sh: &Sheet, c: CellRef, merges: &[RangeRef]) -> bool { sh.value_ref(c).is_none_or(|v| v.is_empty()) && !merges.iter().any(|m| m.contains(c)) } fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f32, f32), merged: bool, merges: &[RangeRef], limits: (f32, f32)) { let (x, y, w, h) = rect; let sh = b.sh; let wb = b.wb; let v = sh.value(c); if v.is_empty() || l.cf.as_ref().is_some_and(|cf| cf.hide_value) { return; } if !sh.show_zeros && v.as_f64() == Some(0.0) { return; } let st = &l.style; let mut size = st.font.size.clamp(1.0, 409.0) * b.scale; let font = font_for(st); let pad = PAD_PX * b.k; let indent = st.align.indent as f32 * 9.0 * b.k; let avail = (w - 2.0 * pad - indent).max(0.0); let code = st.num_fmt.as_str(); let formatted = crate::display::format(&v, code, wb); let mut color = formatted.color.map(numfmt_rgb).or_else(|| rgb_of(st.font.color, wb)).unwrap_or([0, 0, 0]); if l.cf.as_ref().and_then(|x| x.style.as_ref()).is_some_and(|s| s.font.color != Color::Auto) { color = rgb_of(st.font.color, wb).unwrap_or(color); } let is_num = matches!(v, Value::Number(_)); let wrap = st.align.wrap; let mut text = if sh.show_formulas { crate::display::cell_text(wb, sh, c) } else if code == "General" && let Value::Number(n) = v { let digit = gridcraft_pdf::text_width(font, size, "0").max(0.1); let max_chars = (avail / digit).floor().max(1.0) as usize; gridcraft_numfmt::format_general_fit(n, max_chars.min(11)).unwrap_or_else(|| "#".repeat(max_chars.min(255))) } else { crate::display::cell_text(wb, sh, c) }; if !wrap { text = text.replace(['\n', '\r'], " "); } if text.is_empty() { return; } let mut tw = gridcraft_pdf::text_width(font, size, &text); if st.align.shrink && !wrap && tw > avail && tw > 0.0 { size = (size * avail / tw).max(1.0); tw = gridcraft_pdf::text_width(font, size, &text); } if is_num && !wrap && tw > avail + 0.01 && !sh.show_formulas { let hash = gridcraft_pdf::text_width(font, size, "#").max(0.1); text = "#".repeat(((avail / hash).floor().max(1.0) as usize).min(255)); tw = gridcraft_pdf::text_width(font, size, &text); } let h_align = match st.align.h { HAlign::General => { if is_num { HAlign::Right } else if matches!(v, Value::Bool(_) | Value::Error(_)) { HAlign::Center } else { HAlign::Left } } HAlign::CenterAcross | HAlign::Distributed => HAlign::Center, HAlign::Justify | HAlign::Fill => HAlign::Left, other => other, }; let rot = st.align.rotation; let rotated = rot != 0 && rot != 255; // Rotated text wraps along its own direction: upright (±90°) text to the row height. let lines: Vec = match (wrap, rotated) { (true, false) => wrap_lines(&text, font, size, avail.max(size)), (true, true) if rot.abs() == 90 => wrap_lines(&text, font, size, (h - 2.0 * pad).max(size)), (true, true) => vec![text.lines().next().unwrap_or_default().to_string()], (false, _) => vec![text], }; let line_h = size * 1.2; let n = lines.len().max(1) as f32; let first_baseline = match st.align.v { VAlign::Top => y + pad + size * 0.8, VAlign::Center | VAlign::Justify | VAlign::Distributed => y + (h - n * line_h) / 2.0 + line_h * 0.5 + size * 0.33, VAlign::Bottom => y + h - pad - size * 0.22 - (n - 1.0) * line_h, }; // Overflow into empty neighbours (single-line text, not merged). let (mut clip_l, mut clip_r) = (x, x + w); if !wrap && !merged && !is_num && rot.abs() != 90 && tw + 2.0 * pad + indent > w { let need = tw + 2.0 * pad + indent - w; let grow = |dir: i64, mut need: f32| -> f32 { let mut ext = 0.0; let mut col = c.col as i64 + dir; while need > 0.0 && col >= 0 && col < MAX_COLS as i64 { let nc = CellRef::new(c.row, col as u32); if !empty_for_overflow(sh, nc, merges) { break; } let cw = sh.col_width(nc.col) * b.k; ext += cw; need -= cw; col += dir; if ext > 20_000.0 { break; } } ext }; match h_align { HAlign::Right => clip_l -= grow(-1, need), HAlign::Center => { clip_l -= grow(-1, need / 2.0); clip_r += grow(1, need / 2.0); } _ => clip_r += grow(1, need), } } let clip_l = clip_l.max(limits.0); let clip_r = clip_r.min(limits.1); pg.save(); pg.clip_rect(clip_l, y, (clip_r - clip_l).max(0.0), h); if rotated { // Rotated text: anchored at the bottom-left (upward) or top-left (downward) of the cell. // Wrapped lines stack rightward for upward text and leftward for downward text, so // downward text starts its first line far enough in for the last to end at the left pad. let deg = (rot as f32).clamp(-90.0, 90.0); let angle = -deg.to_radians(); let last = lines.len().saturating_sub(1) as f32; for (i, t) in lines.iter().enumerate() { let i = i as f32; let (ox, oy) = if deg > 0.0 { (x + pad + size * 0.8 * deg.to_radians().sin() + i * line_h, y + h - pad) } else { (x + pad + (last - i) * line_h, y + pad + size * 0.8) }; pg.text_rotated(ox, oy, size, font, color, t, angle); } pg.restore(); return; } if rot == 255 { // Vertical stacked text. let chars: Vec = lines.concat().chars().take(500).collect(); let mut yy = y + pad + size * 0.8; for ch in chars { let s = ch.to_string(); let cw = gridcraft_pdf::text_width(font, size, &s); pg.text(x + (w - cw) / 2.0, yy, size, font, color, &s); yy += line_h; if yy > y + h + line_h { break; } } pg.restore(); return; } for (i, line) in lines.iter().enumerate() { let lw = gridcraft_pdf::text_width(font, size, line); let lx = match h_align { HAlign::Right => x + w - pad - lw - indent, HAlign::Center => x + (w - lw) / 2.0, _ => x + pad + indent, }; let ly = first_baseline + i as f32 * line_h; if ly - size > y + h || ly < y - line_h { continue; } pg.text(lx, ly, size, font, color, line); if st.font.underline != Underline::None { let uw = (size * 0.06).max(0.2); pg.stroke_line(lx, ly + size * 0.12, lx + lw, ly + size * 0.12, uw, color); if matches!(st.font.underline, Underline::Double | Underline::DoubleAccounting) { pg.stroke_line(lx, ly + size * 0.24, lx + lw, ly + size * 0.24, uw, color); } } if st.font.strike { pg.stroke_line(lx, ly - size * 0.3, lx + lw, ly - size * 0.3, (size * 0.06).max(0.2), color); } } pg.restore(); } /// Sheet pixel rectangle of an anchor. fn anchor_rect(sh: &Sheet, a: &Anchor) -> (f64, f64, f64, f64) { (sh.col_left(a.cell.col) + a.dx as f64, sh.row_top(a.cell.row) + a.dy as f64, a.width.max(0.0) as f64, a.height.max(0.0) as f64) } /// Decodes an embedded picture to RGBA, downscaled to at most `max_w`×`max_h` pixels. fn decode_image(data: &[u8], max_w: u32, max_h: u32) -> Option<(Vec, u32, u32)> { let (w, h) = crate::io::image_size(data)?; if w == 0 || h == 0 || w as u64 * h as u64 > 60_000_000 { return None; } let img = image::load_from_memory(data).ok()?; let img = if img.width() > max_w.max(1) || img.height() > max_h.max(1) { img.thumbnail(max_w.max(1), max_h.max(1)) } else { img }; let rgba = img.to_rgba8(); let (pw, ph) = (rgba.width(), rgba.height()); Some((rgba.into_raw(), pw, ph)) } /// Draws charts, pictures, shapes and sparklines overlapping the body block of a page. fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block) { let block = b.range(); let (sx0, sy0) = (sh.col_left(block.start.col), sh.row_top(block.start.row)); let (sx1, sy1) = (sh.col_left(block.end.col.saturating_add(1)), sh.row_top(block.end.row.saturating_add(1))); let (bx, by) = (b.xs.first().copied().unwrap_or(0.0), b.ys.first().copied().unwrap_or(0.0)); let (bw, bh) = (b.xs.last().copied().unwrap_or(bx) - bx, b.ys.last().copied().unwrap_or(by) - by); let visible = |r: (f64, f64, f64, f64)| r.2 > 0.0 && r.3 > 0.0 && r.0 < sx1 && r.0 + r.2 > sx0 && r.1 < sy1 && r.1 + r.3 > sy0; let place = |r: (f64, f64, f64, f64)| (bx + ((r.0 - sx0) as f32) * b.k, by + ((r.1 - sy0) as f32) * b.k, r.2 as f32 * b.k, r.3 as f32 * b.k); pg.save(); pg.clip_rect(bx, by, bw, bh); for sp in &sh.sparklines { if !block.contains(sp.cell) { continue; } let vals: Vec> = match gridcraft_calc::evaluate(wb, si, sp.cell, &sp.source) { Value::Array(a) => a.iter().take(10_000).map(Value::as_f64).collect(), v => vec![v.as_f64()], }; let color = rgb_of(sp.color, wb).unwrap_or([0x15, 0x60, 0x82]); let (x, y, w, h) = b.rect(RangeRef::cell(sp.cell)); let (wpx, hpx) = (w / b.k, h / b.k); let prims = gridcraft_chart::render_sparkline(sp.kind, &vals, [color[0], color[1], color[2], 255], sp.markers, wpx, hpx); draw_prims(pg, &prims, Xf { ox: x, oy: y, k: b.k }); } for im in &sh.images { let r = anchor_rect(sh, &im.anchor); if !visible(r) { continue; } let (x, y, w, h) = place(r); // About 150 dpi is plenty for print. let (mw, mh) = (((w / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32, ((h / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32); if let Some((px, pw, ph)) = decode_image(&im.data, mw, mh) { pg.image_rgba(x, y, w, h, &px, pw, ph); } } for shp in &sh.shapes { let r = anchor_rect(sh, &shp.anchor); if !visible(r) { continue; } let (x, y, w, h) = place(r); let fill = rgb_of(shp.fill, wb); let line = rgb_of(shp.line, wb).filter(|_| !shp.format.no_line).map(|c| (c, (0.75 * b.k).max(0.25))); match shp.kind { ShapeKind::Ellipse => { let pts: Vec<[f32; 2]> = (0..72) .map(|i| { let a = i as f32 / 72.0 * std::f32::consts::TAU; [x + w / 2.0 + w / 2.0 * a.cos(), y + h / 2.0 + h / 2.0 * a.sin()] }) .collect(); pg.polygon(&pts, fill, line); } ShapeKind::Triangle => pg.polygon(&[[x + w / 2.0, y], [x + w, y + h], [x, y + h]], fill, line), ShapeKind::Line | ShapeKind::Arrow => { let c = line.map(|l| l.0).or(fill).unwrap_or([0, 0, 0]); pg.stroke_line(x, y, x + w, y + h, (1.0 * b.k).max(0.3), c); if shp.kind == ShapeKind::Arrow { let ang = h.atan2(w); let al = 8.0 * b.k; let p = |da: f32| [x + w - al * (ang + da).cos(), y + h - al * (ang + da).sin()]; pg.polygon(&[[x + w, y + h], p(0.4), p(-0.4)], Some(c), None); } } _ => { pg.polygon(&[[x, y], [x + w, y], [x + w, y + h], [x, y + h]], fill, line); } } if !shp.text.is_empty() { use gridcraft_model::{ShapeTextAlign, ShapeTextAnchor}; let fm = &shp.format; let text_box = shp.kind == ShapeKind::TextBox; let size = fm.font_size.unwrap_or(11.0).clamp(1.0, 4000.0) * b.scale; let font = Font::styled(Font::family_for(fm.font.as_deref().unwrap_or("")), fm.bold, fm.italic); let color = fm.text_color.and_then(|c| rgb_of(c, wb)).unwrap_or([0, 0, 0]); let align = fm.align.unwrap_or(if text_box { ShapeTextAlign::Left } else { ShapeTextAlign::Center }); let anchor = fm.anchor.unwrap_or(if text_box { ShapeTextAnchor::Top } else { ShapeTextAnchor::Middle }); let lines = wrap_lines(&shp.text, font, size, (w - 4.0 * b.k).max(size)); let block_h = lines.len().min(500) as f32 * size * 1.2; let mut yy = match anchor { ShapeTextAnchor::Top => y + 2.0 * b.k, ShapeTextAnchor::Middle => y + (h - block_h) / 2.0, ShapeTextAnchor::Bottom => y + h - 2.0 * b.k - block_h, } + size * 0.8; for l in lines.iter().take(500) { let lw = gridcraft_pdf::text_width(font, size, l); let lx = match align { ShapeTextAlign::Left => x + 2.0 * b.k, ShapeTextAlign::Center => x + (w - lw) / 2.0, ShapeTextAlign::Right => x + w - 2.0 * b.k - lw, }; pg.text(lx, yy, size, font, color, l); yy += size * 1.2; } } } for ch in &sh.charts { let r = anchor_rect(sh, &ch.anchor); if !visible(r) { continue; } let (x, y, _, _) = place(r); let data = gridcraft_chart::resolve(wb, si, ch); let prims = gridcraft_chart::render(ch, &data, r.2 as f32, r.3 as f32, &PdfMeasure); draw_prims(pg, &prims, Xf { ox: x, oy: y, k: b.k }); } pg.restore(); } fn draw_headings(pg: &mut Page, b: &Block, col_heads: bool, row_heads: bool, at: (f32, f32), size: (f32, f32)) { let fill = [0xF2, 0xF2, 0xF2]; let line = [0xA0, 0xA0, 0xA0]; let fsize = 9.0 * b.scale.max(0.1); let lw = (0.35 * b.scale).clamp(0.1, 0.5); if col_heads { let y = at.1; for col in b.cols.0..=b.cols.1 { let (x0, x1) = (b.x(col), b.x(col + 1)); if x1 - x0 <= 0.0 { continue; } pg.fill_rect(x0, y, x1 - x0, size.1, fill); pg.rect_stroke(x0, y, x1 - x0, size.1, lw, line); let t = gridcraft_core::col_to_letters(col); let tw = gridcraft_pdf::text_width(Font::Helvetica, fsize, &t); pg.text(x0 + (x1 - x0 - tw) / 2.0, y + size.1 / 2.0 + fsize * 0.35, fsize, Font::Helvetica, [0x40, 0x40, 0x40], &t); } } if row_heads { let x = at.0; for row in b.rows.0..=b.rows.1 { let (y0, y1) = (b.y(row), b.y(row + 1)); if y1 - y0 <= 0.0 { continue; } pg.fill_rect(x, y0, size.0, y1 - y0, fill); pg.rect_stroke(x, y0, size.0, y1 - y0, lw, line); let t = (row + 1).to_string(); let tw = gridcraft_pdf::text_width(Font::Helvetica, fsize, &t); pg.text(x + (size.0 - tw) / 2.0, y0 + (y1 - y0) / 2.0 + fsize * 0.35, fsize, Font::Helvetica, [0x40, 0x40, 0x40], &t); } } } fn draw_header_footer(pg: &mut Page, lay: &SheetLayout, which: gridcraft_model::HfPage, cx: &HfContext) { let [l, r, _, _, hm, fm] = lay.margins; let pw = lay.page_w; let ph = lay.page_h; for (spec, header) in [(lay.settings.hf(which, false), true), (lay.settings.hf(which, true), false)] { if spec.is_empty() { continue; } let secs = expand_header_footer(spec, cx); for (i, sec) in secs.iter().enumerate() { if sec.text.trim().is_empty() { continue; } let size = sec.size.unwrap_or(10.0).clamp(1.0, 72.0); let font = Font::styled(gridcraft_pdf::Family::Sans, sec.bold, sec.italic); let lines: Vec<&str> = sec.text.split('\n').take(20).collect(); let n = lines.len() as f32; for (j, line) in lines.iter().enumerate() { let lw = gridcraft_pdf::text_width(font, size, line); let x = match i { 0 => l, 1 => (pw - lw) / 2.0, _ => pw - r - lw, }; let y = if header { hm + size * 0.8 + j as f32 * size * 1.2 } else { ph - fm - size * 0.22 - (n - 1.0 - j as f32) * size * 1.2 }; pg.text(x, y, size, font, [0, 0, 0], line); } } } } /// Builds a block for rows × cols positioned at (x, y) on the page. fn make_block<'a>(wb: &'a Workbook, si: usize, sh: &'a Sheet, rows: (u32, u32), cols: (u32, u32), x: f32, y: f32, lay: &SheetLayout) -> Block<'a> { let k = lay.k(); let mut xs = Vec::with_capacity((cols.1 - cols.0 + 2) as usize); let mut acc = x; xs.push(acc); for c in cols.0..=cols.1 { acc += sh.col_width(c) * k; xs.push(acc); } let mut ys = Vec::with_capacity((rows.1 - rows.0 + 2) as usize); let mut acc = y; ys.push(acc); for r in rows.0..=rows.1 { acc += sh.row_height(r) * k; ys.push(acc); } Block { wb, si, sh, rows, cols, xs, ys, k, scale: lay.scale } } /// Segments of a page along one axis: repeated titles (before the band) then the band. fn segments(titles: Option<(u32, u32)>, band: (u32, u32)) -> Vec<(u32, u32)> { let mut v = Vec::new(); if let Some((a, b)) = titles && a < band.0 { v.push((a, b.min(band.0 - 1))); } v.push(band); v } fn seg_size(sh: &Sheet, seg: (u32, u32), rows: bool) -> f32 { if rows { (seg.0..=seg.1).map(|r| sh.row_height(r)).sum() } else { (seg.0..=seg.1).map(|c| sh.col_width(c)).sum() } } /// Renders laid-out sheets to a PDF. pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str) -> (Vec, usize) { let mut doc = PdfDoc::new(); doc.info.title = if wb.props.title.is_empty() { file.to_string() } else { wb.props.title.clone() }; doc.info.author = wb.props.author.clone(); let total: usize = layouts.iter().map(|l| l.pages.len()).sum(); let (date, time) = hf_date_time(wb); let mut page_no = 0usize; for lay in layouts { let Some(sh) = wb.sheet(lay.sheet) else { continue }; let mut cf = if sh.cond_formats.is_empty() { None } else { Some(crate::cf::CfCache::new()) }; let k = lay.k(); let (aw, ah) = lay.avail(); for (pi, (ri, ci)) in lay.pages.iter().enumerate() { page_no += 1; let (Some(rband), Some(cband)) = (lay.row_bands.get(*ri).copied(), lay.col_bands.get(*ci).copied()) else { continue }; let rsegs = segments(lay.title_rows, rband); let csegs = segments(lay.title_cols, cband); let content_w = (lay.heading_w + csegs.iter().map(|s| seg_size(sh, *s, false)).sum::()) * k; let content_h = (lay.heading_h + rsegs.iter().map(|s| seg_size(sh, *s, true)).sum::()) * k; let ox = lay.margins[0] + if lay.settings.center_h { ((aw - content_w) / 2.0).max(0.0) } else { 0.0 }; let oy = lay.margins[2] + if lay.settings.center_v { ((ah - content_h) / 2.0).max(0.0) } else { 0.0 }; let pg = doc.add_page(lay.page_w, lay.page_h); let mut y = oy + lay.heading_h * k; let mut body: Option = None; for (rsi, rs) in rsegs.iter().enumerate() { let mut x = ox + lay.heading_w * k; for (csi, cs) in csegs.iter().enumerate() { let b = make_block(wb, lay.sheet, sh, *rs, *cs, x, y, lay); draw_block(pg, &b, lay.settings.gridlines, &mut cf); if lay.settings.headings { draw_headings(pg, &b, rsi == 0, csi == 0, (ox, oy), (lay.heading_w * k, lay.heading_h * k)); } x += seg_size(sh, *cs, false) * k; if rsi + 1 == rsegs.len() && csi + 1 == csegs.len() { body = Some(b); } } y += seg_size(sh, *rs, true) * k; } if let Some(b) = &body { draw_objects(pg, wb, lay.sheet, sh, b); } if lay.settings.headings { let fill = [0xF2, 0xF2, 0xF2]; pg.fill_rect(ox, oy, lay.heading_w * k, lay.heading_h * k, fill); pg.rect_stroke(ox, oy, lay.heading_w * k, lay.heading_h * k, 0.3, [0xA0, 0xA0, 0xA0]); } let cx = HfContext { page: page_no, pages: total, sheet: sh.name.clone(), file: file.to_string(), path: path.to_string(), date: date.clone(), time: time.clone(), }; draw_header_footer(pg, lay, lay.settings.hf_page(pi == 0, page_no), &cx); } } let n = doc.page_count(); (doc.finish(), n) } // ---------------------------------------------------------------- commands fn build_pdf(s: &Session, p: &Json, cmd: &str) -> Result<(Vec, usize)> { let opts = PrintOptions::from_params(p, cmd)?; let d = s.doc()?; let lays = layouts(&d.wb, &opts); let path = d.path.clone().unwrap_or_default(); Ok(render_pdf(&d.wb, &lays, &d.display_title(), &path)) } pub(crate) fn export_pdf(s: &mut Session, p: &Json) -> Result { let (bytes, pages) = build_pdf(s, p, "file.exportPdf")?; match str_param(p, "path").filter(|x| !x.is_empty()) { Some(path) => { crate::io::write_file(path, &bytes)?; Ok(json!({"pages": pages, "bytes": bytes.len(), "path": path})) } None => Ok(json!({"pages": pages, "bytes": bytes.len(), "base64": crate::io::base64_encode(&bytes)})), } } fn print(s: &mut Session, p: &Json) -> Result { let (bytes, pages) = build_pdf(s, p, "file.print")?; let mut out = json!({"pages": pages, "bytes": bytes.len(), "base64": crate::io::base64_encode(&bytes)}); if let Some(path) = str_param(p, "path").filter(|x| !x.is_empty()) { crate::io::write_file(path, &bytes)?; out["path"] = json!(path); } Ok(out) } fn print_preview(s: &mut Session, p: &Json) -> Result { let opts = PrintOptions::from_params(p, "file.printPreview")?; let d = s.doc()?; let lays = layouts(&d.wb, &opts); let mut list = Vec::new(); let mut n = 0usize; for lay in &lays { let name = d.wb.sheet(lay.sheet).map(|s| s.name.clone()).unwrap_or_default(); for (ri, ci) in &lay.pages { n += 1; let (Some(rb), Some(cb)) = (lay.row_bands.get(*ri), lay.col_bands.get(*ci)) else { continue }; let r = RangeRef::new(CellRef::new(rb.0, cb.0), CellRef::new(rb.1, cb.1)); list.push(json!({ "page": n, "sheet": name, "range": r.a1(), "rows": format!("{}:{}", rb.0 + 1, rb.1 + 1), "columns": format!("{}:{}", gridcraft_core::col_to_letters(cb.0), gridcraft_core::col_to_letters(cb.1)), "scale": (lay.scale * 1000.0).round() / 10.0, "paper": lay.settings.paper, "orientation": if lay.settings.orientation == Orientation::Landscape { "landscape" } else { "portrait" }, "width": lay.page_w, "height": lay.page_h, })); } } if let Some(page) = u32_param(p, "page") { let one = list.get((page as usize).saturating_sub(1)).cloned().ok_or_else(|| bad("file.printPreview", format!("there are only {n} pages")))?; return Ok(json!({"pages": n, "page": one})); } Ok(json!({"pages": n, "pageList": list})) } fn page_setup(s: &mut Session, p: &Json) -> Result { const CMD: &str = "pageLayout.pageSetup"; let si = target_sheet(s, p)?; let orientation = match str_param(p, "orientation") { None => None, Some("portrait") => Some(Orientation::Portrait), Some("landscape") => Some(Orientation::Landscape), Some(o) => return Err(bad(CMD, format!("orientation must be portrait or landscape, not `{o}`"))), }; let paper = match str_param(p, "paper") { None => None, Some(name) => { known_paper(name).ok_or_else(|| bad(CMD, format!("unknown paper size `{name}`")))?; Some(name.trim().to_string()) } }; let margin_src = p.get("margins").filter(|m| m.is_object()).unwrap_or(p); let mut margins: [Option; 6] = [None; 6]; for (i, key) in ["left", "right", "top", "bottom", "header", "footer"].iter().enumerate() { if let Some(v) = f64_param(margin_src, key).or_else(|| f64_param(p, key)) { if !v.is_finite() || v < 0.0 { return Err(bad(CMD, format!("margin `{key}` must be a non-negative number of inches"))); } margins[i] = Some(v.min(20.0) as f32); } } let parse_rows = |key: &str| -> Result>> { match p.get(key) { None => Ok(None), Some(Json::Null) => Ok(Some(None)), Some(Json::String(t)) if t.trim().is_empty() => Ok(Some(None)), Some(Json::String(t)) => { let r = RangeRef::parse(split_sheet(t).1).ok_or_else(|| bad(CMD, format!("`{key}` is not a row range: `{t}`")))?; Ok(Some(Some((r.start.row, r.end.row)))) } Some(_) => Err(bad(CMD, format!("`{key}` must be text like \"1:2\""))), } }; let parse_cols = |key: &str| -> Result>> { match p.get(key) { None => Ok(None), Some(Json::Null) => Ok(Some(None)), Some(Json::String(t)) if t.trim().is_empty() => Ok(Some(None)), Some(Json::String(t)) => { let r = RangeRef::parse(split_sheet(t).1).ok_or_else(|| bad(CMD, format!("`{key}` is not a column range: `{t}`")))?; Ok(Some(Some((r.start.col, r.end.col)))) } Some(_) => Err(bad(CMD, format!("`{key}` must be text like \"A:B\""))), } }; let title_rows = parse_rows("titleRows")?; let title_cols = parse_cols("titleCols")?; let print_area = match p.get("printArea") { None => None, Some(Json::Null) => Some(None), Some(Json::String(t)) if t.trim().is_empty() => Some(None), Some(Json::String(t)) => Some(Some(RangeRef::parse(split_sheet(t).1).ok_or_else(|| bad(CMD, format!("`printArea` is not a range: `{t}`")))?)), Some(_) => return Err(bad(CMD, "`printArea` must be a range like \"A1:F50\"")), }; let row_breaks: Option> = p.get("rowBreaks").and_then(Json::as_array).map(|a| { let mut v: Vec = a .iter() .filter_map(|x| x.as_u64().or_else(|| x.as_str().and_then(|t| t.trim().parse().ok()))) .filter(|r| *r >= 2 && *r <= MAX_ROWS as u64) .map(|r| r as u32 - 1) .take(1026) .collect(); v.sort_unstable(); v.dedup(); v }); let col_breaks: Option> = p.get("colBreaks").and_then(Json::as_array).map(|a| { let mut v: Vec = a .iter() .filter_map(|x| match x { Json::String(t) => gridcraft_core::letters_to_col(t.trim().trim_start_matches('$')), Json::Number(n) => n.as_u64().filter(|c| *c >= 2 && *c <= MAX_COLS as u64).map(|c| c as u32 - 1), _ => None, }) .filter(|c| *c >= 1) .take(1026) .collect(); v.sort_unstable(); v.dedup(); v }); let scale = f64_param(p, "scale").filter(|v| v.is_finite()).map(|v| v.clamp(10.0, 400.0).round() as u16); let fit_w = p.get("fitWidth").map(|v| v.as_u64().unwrap_or(0).min(32767) as u16); let fit_h = p.get("fitHeight").map(|v| v.as_u64().unwrap_or(0).min(32767) as u16); let fit_page = bool_param(p, "fitToPage"); let header = str_param(p, "header").map(|t| t.chars().take(255).collect::()); let footer = str_param(p, "footer").map(|t| t.chars().take(255).collect::()); let flags = [("centerH", 0usize), ("centerV", 1), ("gridlines", 2), ("headings", 3)].map(|(k, i)| (i, bool_param(p, k))); edit(s, |cx| { let pr = &mut cx.sheet_mut(si)?.print; if let Some(o) = orientation { pr.orientation = o; } if let Some(pp) = &paper { pr.paper = pp.clone(); } for (i, m) in margins.iter().enumerate() { if let (Some(v), Some(slot)) = (m, pr.margins.get_mut(i)) { *slot = *v; } } if let Some(sc) = scale { pr.scale = sc; pr.fit_to = None; } if fit_w.is_some() || fit_h.is_some() { let (cw, ch) = pr.fit_to.unwrap_or((1, 1)); pr.fit_to = Some((fit_w.unwrap_or(cw), fit_h.unwrap_or(ch))); } match fit_page { Some(true) => pr.fit_to = Some((1, 1)), Some(false) => pr.fit_to = None, None => {} } for (i, v) in flags { if let Some(v) = v { match i { 0 => pr.center_h = v, 1 => pr.center_v = v, 2 => pr.gridlines = v, _ => pr.headings = v, } } } if let Some(h) = &header { pr.header = h.clone(); } if let Some(f) = &footer { pr.footer = f.clone(); } if let Some(a) = print_area { pr.print_area = a; } if let Some(t) = title_rows { pr.title_rows = t; } if let Some(t) = title_cols { pr.title_cols = t; } if let Some(b) = &row_breaks { pr.row_breaks = b.clone(); } if let Some(b) = &col_breaks { pr.col_breaks = b.clone(); } Ok(()) })?; let pr = s.doc()?.wb.sheet(si).map(|sh| sh.print.clone()).unwrap_or_default(); Ok(serde_json::to_value(&pr).unwrap_or(Json::Null)) } #[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 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_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")); } #[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 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, 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()); } #[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 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 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:?}"); } }