2088 lines
88 KiB
Rust
2088 lines
88 KiB
Rust
//! 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<CommandSpec> {
|
||
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<RangeRef>,
|
||
pub fit_to_page: Option<bool>,
|
||
}
|
||
|
||
impl PrintOptions {
|
||
fn from_params(p: &Json, cmd: &str) -> Result<PrintOptions> {
|
||
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<RangeRef> {
|
||
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<RangeRef>) -> Option<SheetLayout> {
|
||
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::<f32>() + 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::<f32>() + 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<u32> = if lay.manual_breaks { st.row_breaks.clone() } else { vec![] };
|
||
let mut cb: Vec<u32> = 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<SheetLayout> {
|
||
let mut out = Vec::new();
|
||
let mut pages = 0usize;
|
||
let sheets: Vec<usize> = 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<char> = 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<char> = 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<f32>,
|
||
}
|
||
|
||
/// 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<char> = 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::<i64>() {
|
||
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::<f32>() {
|
||
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<Rgb> {
|
||
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<Rgb>,
|
||
cf: Option<crate::cf::CfLook>,
|
||
}
|
||
|
||
fn cell_look(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, cf: &mut Option<crate::cf::CfCache>) -> 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<String> {
|
||
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<f32>,
|
||
ys: Vec<f32>,
|
||
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<f32> {
|
||
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<crate::cf::CfCache>) {
|
||
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<RangeRef> = 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<CellRef> = if block.count() <= DENSE_BLOCK {
|
||
block.iter().filter(|c| !covered(*c)).collect()
|
||
} else {
|
||
let mut set: Vec<CellRef> = 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<String> = 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<char> = 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<u8>, 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<Option<f64>> = 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<u8>, 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::<f32>()) * k;
|
||
let content_h = (lay.heading_h + rsegs.iter().map(|s| seg_size(sh, *s, true)).sum::<f32>()) * 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<Block> = 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<u8>, 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<Json> {
|
||
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<Json> {
|
||
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<Json> {
|
||
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<Json> {
|
||
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<f32>; 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<Option<Option<(u32, u32)>>> {
|
||
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<Option<Option<(u32, u32)>>> {
|
||
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<Vec<u32>> = p.get("rowBreaks").and_then(Json::as_array).map(|a| {
|
||
let mut v: Vec<u32> = 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<Vec<u32>> = p.get("colBreaks").and_then(Json::as_array).map(|a| {
|
||
let mut v: Vec<u32> = 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::<String>());
|
||
let footer = str_param(p, "footer").map(|t| t.chars().take(255).collect::<String>());
|
||
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<u8>, 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<Vec<Json>> = (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<Vec<Json>> = (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:?}");
|
||
}
|
||
}
|