mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-30 05:37:00 -06:00
371 lines
14 KiB
Rust
371 lines
14 KiB
Rust
use super::header_footer::HF_FIXED_SIDE_MARGIN;
|
||
use super::{
|
||
Anchor, CellRef, Json, MAX_COLS, MAX_PAGES, MAX_ROWS, Orientation, PX_TO_PT, PrintSettings, RangeRef, Result, Sheet, Visibility, Workbook, bad,
|
||
bool_param, split_sheet, str_param,
|
||
};
|
||
|
||
/// Cells in a page block above which only stored cells are visited (not every grid position).
|
||
pub(super) 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))
|
||
}
|
||
|
||
/// Paper sizes in points, portrait. `B4`/`B5`/`B6` are the **JIS** sizes Japanese Excel and its
|
||
/// e-mail attachments use (257×364, 182×257 and 128×182 mm); the ISO B series stays reachable as
|
||
/// `B4 (ISO)`/`B5 (ISO)`/`B6 (ISO)`. The Japanese postcards and envelopes are the JIS ones too.
|
||
pub(super) fn known_paper(name: &str) -> Option<(f32, f32)> {
|
||
// Spaces, hyphens, underscores and parentheses don't matter: `B5 (JIS)`, `b5-jis` and `b5`
|
||
// are one name.
|
||
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),
|
||
"a6" => (297.64, 419.53),
|
||
"tabloid" | "11x17" => (792.0, 1224.0),
|
||
"ledger" => (1224.0, 792.0),
|
||
"executive" => (522.0, 756.0),
|
||
"b4" | "b4jis" => (728.50, 1031.81),
|
||
"b5" | "b5jis" => (515.91, 728.50),
|
||
"b6" | "b6jis" => (362.83, 515.91),
|
||
"b4iso" => (708.66, 1000.63),
|
||
"b5iso" => (498.90, 708.66),
|
||
"b6iso" => (354.33, 498.90),
|
||
"japanesepostcard" | "postcard" => (283.46, 419.53),
|
||
"japanesedoublepostcard" | "doublepostcard" => (419.53, 566.93),
|
||
"japaneseenvelope#3" | "chou#3" => (340.16, 666.14),
|
||
// 長形4号 is 90×205 mm; 90×225 mm is 長形40号, a different envelope, so it stays out.
|
||
"japaneseenvelope#4" | "chou#4" => (255.12, 581.10),
|
||
"japaneseenvelopekaku#2" | "kaku#2" => (680.31, 941.10),
|
||
"japaneseenvelopekaku#3" | "kaku#3" => (612.28, 785.20),
|
||
_ => 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 {
|
||
pub(super) 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))
|
||
}
|
||
/// Left and right margins, in points, that header and footer sections line up with: the
|
||
/// page's side margins with Align with Page Margins on, otherwise 0.7 inch on each side, at
|
||
/// most a quarter of the page width.
|
||
pub(crate) fn hf_sides(&self) -> (f32, f32) {
|
||
if self.settings.hf_align_with_margins {
|
||
(self.margins[0], self.margins[1])
|
||
} else {
|
||
let side = HF_FIXED_SIDE_MARGIN.min(self.page_w / 4.0);
|
||
(side, side)
|
||
}
|
||
}
|
||
/// Scale of header and footer text: the print scale with Scale with Document on, otherwise 1.
|
||
pub(crate) fn hf_text_scale(&self) -> f32 {
|
||
if self.settings.hf_scale_with_doc { self.scale } else { 1.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 {
|
||
pub(super) 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.
|
||
pub(super) 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)
|
||
}
|
||
|
||
/// Inclusive-start/end index spans of the pages along one axis.
|
||
pub(crate) type Bands = Vec<(u32, u32)>;
|
||
|
||
/// 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) -> (Bands, Bands) {
|
||
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
|
||
}
|
||
|
||
/// Segments of a page along one axis: repeated titles (before the band) then the band.
|
||
pub(super) 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
|
||
}
|
||
|
||
pub(super) 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() }
|
||
}
|