Skip to main content

ravenrs_sheet/engine/cmd/
print.rs

1//! Printing: Page Setup, pagination and PDF output (File › Print, File › Export › PDF).
2//!
3//! Sheets are paginated like Excel: the print area (or the used range plus drawing objects) is
4//! split into bands of rows and columns that fit the printable area at the page scale, honouring
5//! manual breaks, repeated title rows/columns, headings, Fit To and centring, and the pages follow
6//! the sheet's page order ("down, then over" by default, or "over, then down"). Each page is
7//! drawn into a PDF with `gridcraft-pdf`: fills, gridlines, borders, formatted text (fonts mapped
8//! to the standard 14 PDF fonts), merged cells, wrapping, overflow into empty neighbours,
9//! conditional formats, table styles, charts, images, shapes and sparklines. Headers and footers
10//! support `&L`/`&C`/`&R` sections and the `&P &N &D &T &A &F &Z` codes.
11
12use crate::base::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value};
13#[cfg(test)]
14use crate::model::{Alignment, Image, SheetViewMode};
15use crate::model::{
16    Anchor, BorderLine, BorderStyle, Borders, Color, HAlign, Orientation, PageOrder, PatternType, PrintSettings, ShapeKind, Sheet, Style, StyleId,
17    StyleTable, Underline, VAlign, Visibility, Workbook,
18};
19use crate::pdf::{Font, Page, PdfDoc, Point, Rect, Rgb};
20use serde_json::{Value as Json, json};
21
22use super::{CommandSpec, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, split_sheet, str_param, target_sheet, u32_param};
23
24mod draw;
25mod header_footer;
26mod layout;
27mod objects;
28mod text;
29
30use draw::{Block, PrintedBlock, draw_block, draw_block_links, draw_headings, make_block, printed_target};
31use header_footer::draw_header_footer;
32pub use header_footer::{
33    HF_FIELDS, HfContext, HfSection, codes_to_fields, expand_header_footer, fields_to_codes, hf_date_time, join_header_footer, split_header_footer,
34};
35/// Decoded pictures (bytes, width, height) keyed by (sheet, picture); `None` = undecodable.
36type PicCache = std::collections::HashMap<(usize, usize), Option<(Vec<u8>, u32, u32)>>;
37
38#[cfg(test)]
39use layout::bands;
40pub(crate) use layout::split_bands;
41pub use layout::{PrintOptions, SheetLayout, layout_sheet, layouts, paper_size, print_range};
42use layout::{known_paper, seg_size, segments};
43use objects::draw_objects;
44#[cfg(test)]
45use text::{center_across_width, wrap_lines};
46
47pub fn specs() -> Vec<CommandSpec> {
48    vec![
49        cmd!(
50            noundo "file.exportPdf",
51            "Export as PDF",
52            ["File", "Export"],
53            None,
54            "{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.",
55            has_doc,
56            export_pdf
57        ),
58        cmd!(
59            noundo "file.print",
60            "Print…",
61            ["File"],
62            Some("Cmd+P"),
63            "{path?, sheets?: active|all, range?, fitToPage?: bool} → {pages, bytes, base64} — the PDF the UI hands to the system print dialog",
64            has_doc,
65            print
66        ),
67        cmd!(
68            query "file.printPreview",
69            "Print Preview",
70            ["File"],
71            None,
72            "{page? (1-based), sheets?: active|all, range?, fitToPage?} → {pages, pageList: [{page, sheet, range, rows, columns, scale, paper, orientation, pageOrder, width, height}]} in print order",
73            has_doc,
74            print_preview
75        ),
76        cmd!(
77            "pageLayout.pageSetup",
78            "Page Setup…",
79            ["Page Layout", "Page Setup"],
80            None,
81            "{sheet?, orientation?: portrait|landscape, paper?: Letter|Legal|A4|A3|A5|A6|Tabloid|Executive|Ledger|B4|B5|B6|B4 (ISO)|B5 (ISO)|B6 (ISO)|Japanese Postcard|Japanese Double Postcard|Japanese Envelope #3|Japanese Envelope #4|Japanese Envelope Kaku #2|Japanese Envelope Kaku #3 (B4/B5/B6 are the JIS sizes), margins?: {left,right,top,bottom,header,footer} (inches; also accepted as top-level keys), scale?: 10..400, fitWidth?, fitHeight? (pages; 0 = automatic), fitToPage?: bool, centerH?, centerV?, gridlines?, headings?, header?: \"&L&A&RPage &P of &N\", footer?, printArea?: \"A1:F50\" (\"\" clears), titleRows?: \"1:2\", titleCols?: \"A:A\", rowBreaks?: [46, 91], colBreaks?: [\"K\"], pageOrder?: downThenOver|overThenDown} → the sheet's print settings",
82            has_doc,
83            page_setup
84        ),
85    ]
86}
87
88/// Sheet geometry is in pixels at 96 dpi; PDF units are points.
89pub(crate) const PX_TO_PT: f32 = 0.75;
90/// Most pages one export produces.
91pub const MAX_PAGES: usize = 2000;
92
93/// Renders laid-out sheets to a PDF.
94pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str) -> (Vec<u8>, usize) {
95    let mut doc = PdfDoc::new();
96    doc.info.title = if wb.props.title.is_empty() { file.to_string() } else { wb.props.title.clone() };
97    doc.info.author = wb.props.author.clone();
98    let total: usize = layouts.iter().map(|l| l.pages.len()).sum();
99    let (date, time) = hf_date_time(wb);
100    let mut page_no = 0usize;
101    let mut pending_links = Vec::new();
102    let mut printed_blocks = Vec::new();
103    // Decoded pictures are reused across the pages their anchor covers: a floating picture
104    // spanning 40 pages was decoded (and pushed into the PDF) 40 times (#2389).
105    let mut pics = PicCache::new();
106    for lay in layouts {
107        let Some(sh) = wb.sheet(lay.sheet) else { continue };
108        let mut cf = if sh.cond_formats.is_empty() { None } else { Some(crate::engine::cf::CfCache::new()) };
109        let k = lay.k();
110        let (aw, ah) = lay.avail();
111        for (pi, (ri, ci)) in lay.pages.iter().enumerate() {
112            page_no += 1;
113            let (Some(rband), Some(cband)) = (lay.row_bands.get(*ri).copied(), lay.col_bands.get(*ci).copied()) else { continue };
114            let rsegs = segments(lay.title_rows, rband);
115            let csegs = segments(lay.title_cols, cband);
116            let content_w = (lay.heading_w + csegs.iter().map(|s| seg_size(sh, *s, false)).sum::<f32>()) * k;
117            let content_h = (lay.heading_h + rsegs.iter().map(|s| seg_size(sh, *s, true)).sum::<f32>()) * k;
118            let ox = lay.margins[0] + if lay.settings.center_h { ((aw - content_w) / 2.0).max(0.0) } else { 0.0 };
119            let oy = lay.margins[2] + if lay.settings.center_v { ((ah - content_h) / 2.0).max(0.0) } else { 0.0 };
120            let page_index = doc.page_count();
121            let pg = doc.add_page(lay.page_w, lay.page_h);
122            let mut y = oy + lay.heading_h * k;
123            let mut body: Option<Block> = None;
124            for (rsi, rs) in rsegs.iter().enumerate() {
125                let mut x = ox + lay.heading_w * k;
126                for (csi, cs) in csegs.iter().enumerate() {
127                    let b = make_block(wb, lay.sheet, sh, *rs, *cs, (x, y), lay);
128                    draw_block(pg, &b, lay.settings.gridlines, &mut cf);
129                    draw_block_links(pg, &b, page_index, &mut pending_links);
130                    printed_blocks.push(PrintedBlock { page: page_index, sheet: lay.sheet, rows: *rs, cols: *cs, x, y, k });
131                    if lay.settings.headings {
132                        draw_headings(pg, &b, rsi == 0, csi == 0, (ox, oy), (lay.heading_w * k, lay.heading_h * k));
133                    }
134                    x += seg_size(sh, *cs, false) * k;
135                    if rsi + 1 == rsegs.len() && csi + 1 == csegs.len() {
136                        body = Some(b);
137                    }
138                }
139                y += seg_size(sh, *rs, true) * k;
140            }
141            if let Some(b) = &body {
142                draw_objects(pg, wb, lay.sheet, sh, b, &mut pics);
143            }
144            if lay.settings.headings {
145                let fill = [0xF2, 0xF2, 0xF2];
146                pg.fill_rect(ox, oy, lay.heading_w * k, lay.heading_h * k, fill);
147                pg.rect_stroke(ox, oy, lay.heading_w * k, lay.heading_h * k, 0.3, [0xA0, 0xA0, 0xA0]);
148            }
149            let cx = HfContext {
150                page: page_no,
151                pages: total,
152                sheet: sh.name.clone(),
153                file: file.to_string(),
154                path: path.to_string(),
155                date: date.clone(),
156                time: time.clone(),
157            };
158            draw_header_footer(pg, lay, lay.settings.hf_page(pi == 0, page_no), &cx);
159        }
160    }
161    for link in pending_links {
162        if let Some((target_page, x, y)) = printed_target(wb, &printed_blocks, link.sheet, &link.target)
163            && let Some(page) = doc.page_mut(link.page)
164        {
165            let (lx, ly, w, h) = link.rect;
166            page.link_page(Rect::new(lx, ly, w, h), target_page, Point::new(x, y));
167        }
168    }
169    let n = doc.page_count();
170    (doc.finish(), n)
171}
172
173// ---------------------------------------------------------------- commands
174
175fn build_pdf(s: &Session, p: &Json, cmd: &str) -> Result<(Vec<u8>, usize)> {
176    let opts = PrintOptions::from_params(p, cmd)?;
177    let d = s.doc()?;
178    let lays = layouts(&d.wb, &opts);
179    let path = d.path.clone().unwrap_or_default();
180    Ok(render_pdf(&d.wb, &lays, &d.display_title(), &path))
181}
182
183pub(crate) fn export_pdf(s: &mut Session, p: &Json) -> Result<Json> {
184    let (bytes, pages) = build_pdf(s, p, "file.exportPdf")?;
185    match str_param(p, "path").filter(|x| !x.is_empty()) {
186        Some(path) => {
187            crate::engine::io::write_file(path, &bytes)?;
188            Ok(json!({"pages": pages, "bytes": bytes.len(), "path": path}))
189        }
190        None => Ok(json!({"pages": pages, "bytes": bytes.len(), "base64": crate::engine::io::base64_encode(&bytes)})),
191    }
192}
193
194fn print(s: &mut Session, p: &Json) -> Result<Json> {
195    let (bytes, pages) = build_pdf(s, p, "file.print")?;
196    let mut out = json!({"pages": pages, "bytes": bytes.len(), "base64": crate::engine::io::base64_encode(&bytes)});
197    if let Some(path) = str_param(p, "path").filter(|x| !x.is_empty()) {
198        crate::engine::io::write_file(path, &bytes)?;
199        out["path"] = json!(path);
200    }
201    Ok(out)
202}
203
204fn print_preview(s: &mut Session, p: &Json) -> Result<Json> {
205    let opts = PrintOptions::from_params(p, "file.printPreview")?;
206    let d = s.doc()?;
207    let lays = layouts(&d.wb, &opts);
208    let mut list = Vec::new();
209    let mut n = 0usize;
210    for lay in &lays {
211        let name = d.wb.sheet(lay.sheet).map(|s| s.name.clone()).unwrap_or_default();
212        for (ri, ci) in &lay.pages {
213            n += 1;
214            let (Some(rb), Some(cb)) = (lay.row_bands.get(*ri), lay.col_bands.get(*ci)) else { continue };
215            let r = RangeRef::new(CellRef::new(rb.0, cb.0), CellRef::new(rb.1, cb.1));
216            list.push(json!({
217                "page": n,
218                "sheet": name,
219                "range": r.a1(),
220                "rows": format!("{}:{}", rb.0 + 1, rb.1 + 1),
221                "columns": format!("{}:{}", crate::base::col_to_letters(cb.0), crate::base::col_to_letters(cb.1)),
222                "scale": (lay.scale * 1000.0).round() / 10.0,
223                "paper": lay.settings.paper,
224                "orientation": if lay.settings.orientation == Orientation::Landscape { "landscape" } else { "portrait" },
225                "pageOrder": lay.settings.page_order.id(),
226                "width": lay.page_w,
227                "height": lay.page_h,
228            }));
229        }
230    }
231    if let Some(page) = u32_param(p, "page") {
232        let one =
233            list.get((page as usize).saturating_sub(1)).cloned().ok_or_else(|| bad("file.printPreview", format!("there are only {n} pages")))?;
234        return Ok(json!({"pages": n, "page": one}));
235    }
236    Ok(json!({"pages": n, "pageList": list}))
237}
238
239fn page_setup(s: &mut Session, p: &Json) -> Result<Json> {
240    const CMD: &str = "pageLayout.pageSetup";
241    let si = target_sheet(s, p)?;
242    let orientation = match str_param(p, "orientation") {
243        None => None,
244        Some("portrait") => Some(Orientation::Portrait),
245        Some("landscape") => Some(Orientation::Landscape),
246        Some(o) => return Err(bad(CMD, format!("orientation must be portrait or landscape, not `{o}`"))),
247    };
248    let page_order = match p.get("pageOrder") {
249        None => None,
250        Some(v) => match v.as_str().and_then(PageOrder::parse) {
251            Some(o) => Some(o),
252            None => {
253                let shown = v.as_str().map_or_else(|| v.to_string(), str::to_string);
254                return Err(bad(CMD, format!("pageOrder must be downThenOver or overThenDown, not `{shown}`")));
255            }
256        },
257    };
258    let paper = match str_param(p, "paper") {
259        None => None,
260        Some(name) => {
261            known_paper(name).ok_or_else(|| bad(CMD, format!("unknown paper size `{name}`")))?;
262            Some(name.trim().to_string())
263        }
264    };
265    let margin_src = p.get("margins").filter(|m| m.is_object()).unwrap_or(p);
266    let mut margins: [Option<f32>; 6] = [None; 6];
267    for (i, key) in ["left", "right", "top", "bottom", "header", "footer"].iter().enumerate() {
268        if let Some(v) = f64_param(margin_src, key).or_else(|| f64_param(p, key)) {
269            if !v.is_finite() || v < 0.0 {
270                return Err(bad(CMD, format!("margin `{key}` must be a non-negative number of inches")));
271            }
272            margins[i] = Some(v.min(20.0) as f32);
273        }
274    }
275    let parse_rows = |key: &str| -> Result<Option<Option<(u32, u32)>>> {
276        match p.get(key) {
277            None => Ok(None),
278            Some(Json::Null) => Ok(Some(None)),
279            Some(Json::String(t)) if t.trim().is_empty() => Ok(Some(None)),
280            Some(Json::String(t)) => {
281                let r = RangeRef::parse(split_sheet(t).1).ok_or_else(|| bad(CMD, format!("`{key}` is not a row range: `{t}`")))?;
282                Ok(Some(Some((r.start.row, r.end.row))))
283            }
284            Some(_) => Err(bad(CMD, format!("`{key}` must be text like \"1:2\""))),
285        }
286    };
287    let parse_cols = |key: &str| -> Result<Option<Option<(u32, u32)>>> {
288        match p.get(key) {
289            None => Ok(None),
290            Some(Json::Null) => Ok(Some(None)),
291            Some(Json::String(t)) if t.trim().is_empty() => Ok(Some(None)),
292            Some(Json::String(t)) => {
293                let r = RangeRef::parse(split_sheet(t).1).ok_or_else(|| bad(CMD, format!("`{key}` is not a column range: `{t}`")))?;
294                Ok(Some(Some((r.start.col, r.end.col))))
295            }
296            Some(_) => Err(bad(CMD, format!("`{key}` must be text like \"A:B\""))),
297        }
298    };
299    let title_rows = parse_rows("titleRows")?;
300    let title_cols = parse_cols("titleCols")?;
301    let print_area = match p.get("printArea") {
302        None => None,
303        Some(Json::Null) => Some(None),
304        Some(Json::String(t)) if t.trim().is_empty() => Some(None),
305        Some(Json::String(t)) => Some(Some(RangeRef::parse(split_sheet(t).1).ok_or_else(|| bad(CMD, format!("`printArea` is not a range: `{t}`")))?)),
306        Some(_) => return Err(bad(CMD, "`printArea` must be a range like \"A1:F50\"")),
307    };
308    let row_breaks: Option<Vec<u32>> = p.get("rowBreaks").and_then(Json::as_array).map(|a| {
309        let mut v: Vec<u32> = a
310            .iter()
311            .filter_map(|x| x.as_u64().or_else(|| x.as_str().and_then(|t| t.trim().parse().ok())))
312            .filter(|r| *r >= 2 && *r <= MAX_ROWS as u64)
313            .map(|r| r as u32 - 1)
314            .take(1026)
315            .collect();
316        v.sort_unstable();
317        v.dedup();
318        v
319    });
320    let col_breaks: Option<Vec<u32>> = p.get("colBreaks").and_then(Json::as_array).map(|a| {
321        let mut v: Vec<u32> = a
322            .iter()
323            .filter_map(|x| match x {
324                Json::String(t) => crate::base::letters_to_col(t.trim().trim_start_matches('$')),
325                Json::Number(n) => n.as_u64().filter(|c| *c >= 2 && *c <= MAX_COLS as u64).map(|c| c as u32 - 1),
326                _ => None,
327            })
328            .filter(|c| *c >= 1)
329            .take(1026)
330            .collect();
331        v.sort_unstable();
332        v.dedup();
333        v
334    });
335    let scale = f64_param(p, "scale").filter(|v| v.is_finite()).map(|v| v.clamp(10.0, 400.0).round() as u16);
336    let fit_w = p.get("fitWidth").map(|v| v.as_u64().unwrap_or(0).min(32767) as u16);
337    let fit_h = p.get("fitHeight").map(|v| v.as_u64().unwrap_or(0).min(32767) as u16);
338    let fit_page = bool_param(p, "fitToPage");
339    let header = str_param(p, "header").map(|t| t.chars().take(255).collect::<String>());
340    let footer = str_param(p, "footer").map(|t| t.chars().take(255).collect::<String>());
341    let flags = [("centerH", 0usize), ("centerV", 1), ("gridlines", 2), ("headings", 3)].map(|(k, i)| (i, bool_param(p, k)));
342    edit(s, |cx| {
343        let pr = &mut cx.sheet_mut(si)?.print;
344        if let Some(o) = orientation {
345            pr.orientation = o;
346        }
347        if let Some(pp) = &paper {
348            pr.paper = pp.clone();
349        }
350        for (i, m) in margins.iter().enumerate() {
351            if let (Some(v), Some(slot)) = (m, pr.margins.get_mut(i)) {
352                *slot = *v;
353            }
354        }
355        if let Some(sc) = scale {
356            pr.scale = sc;
357            pr.fit_to = None;
358        }
359        if fit_w.is_some() || fit_h.is_some() {
360            let (cw, ch) = pr.fit_to.unwrap_or((1, 1));
361            pr.fit_to = Some((fit_w.unwrap_or(cw), fit_h.unwrap_or(ch)));
362        }
363        match fit_page {
364            Some(true) => pr.fit_to = Some((1, 1)),
365            Some(false) => pr.fit_to = None,
366            None => {}
367        }
368        for (i, v) in flags {
369            if let Some(v) = v {
370                match i {
371                    0 => pr.center_h = v,
372                    1 => pr.center_v = v,
373                    2 => pr.gridlines = v,
374                    _ => pr.headings = v,
375                }
376            }
377        }
378        if let Some(h) = &header {
379            pr.header = h.clone();
380        }
381        if let Some(f) = &footer {
382            pr.footer = f.clone();
383        }
384        if let Some(a) = print_area {
385            pr.print_area = a;
386        }
387        if let Some(t) = title_rows {
388            pr.title_rows = t;
389        }
390        if let Some(t) = title_cols {
391            pr.title_cols = t;
392        }
393        if let Some(b) = &row_breaks {
394            pr.row_breaks = b.clone();
395        }
396        if let Some(b) = &col_breaks {
397            pr.col_breaks = b.clone();
398        }
399        if let Some(o) = page_order {
400            pr.page_order = o;
401        }
402        Ok(())
403    })?;
404    let pr = s.doc()?.wb.sheet(si).map(|sh| sh.print.clone()).unwrap_or_default();
405    Ok(serde_json::to_value(&pr).unwrap_or(Json::Null))
406}
407
408#[cfg(test)]
409mod tests;