From 73c52d49b7659c2ceaa9991a26f4595e59571fd2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:24:30 +0000 Subject: [PATCH] refactor(engine): move cmd/print.rs items to cmd/print/ --- crates/engine/src/cmd/print.rs | 1520 +----------------- crates/engine/src/cmd/print/draw.rs | 467 ++++++ crates/engine/src/cmd/print/header_footer.rs | 261 +++ crates/engine/src/cmd/print/layout.rs | 364 +++++ crates/engine/src/cmd/print/objects.rs | 177 ++ crates/engine/src/cmd/print/text.rs | 263 +++ 6 files changed, 1552 insertions(+), 1500 deletions(-) create mode 100644 crates/engine/src/cmd/print/draw.rs create mode 100644 crates/engine/src/cmd/print/header_footer.rs create mode 100644 crates/engine/src/cmd/print/layout.rs create mode 100644 crates/engine/src/cmd/print/objects.rs create mode 100644 crates/engine/src/cmd/print/text.rs diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index d55841f7b..3b6dcb0ec 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -16,6 +16,26 @@ use serde_json::{Value as Json, json}; use super::*; +mod draw; +mod header_footer; +mod layout; +mod objects; +mod text; + +use draw::{Block, PrintedBlock, draw_block, draw_block_links, draw_headings, make_block, printed_target}; +use header_footer::draw_header_footer; +pub use header_footer::{ + HF_FIELDS, HfContext, HfSection, codes_to_fields, expand_header_footer, fields_to_codes, hf_date_time, join_header_footer, split_header_footer, +}; +#[cfg(test)] +use layout::bands; +pub(crate) use layout::split_bands; +pub use layout::{PrintOptions, SheetLayout, layout_sheet, layouts, paper_size, print_range}; +use layout::{known_paper, seg_size, segments}; +use objects::draw_objects; +#[cfg(test)] +use text::{center_across_width, wrap_lines}; + pub fn specs() -> Vec { vec![ cmd!( @@ -61,1506 +81,6 @@ pub fn specs() -> Vec { 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)) -} - -/// 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. -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 { - 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 } - } -} - -/// Side margin of headers and footers, in points, when Align with Page Margins is off. -const HF_FIXED_SIDE_MARGIN: f32 = 0.7 * 72.0; - -/// Options shared by the print commands. -#[derive(Clone, Debug, Default)] -pub struct PrintOptions { - pub all_sheets: bool, - pub range: Option, - pub fit_to_page: Option, -} - -impl PrintOptions { - fn from_params(p: &Json, cmd: &str) -> Result { - let all_sheets = match str_param(p, "sheets") { - None | Some("active") => false, - Some("all") | Some("workbook") => true, - Some(other) => return Err(bad(cmd, format!("`sheets` must be active or all, not `{other}`"))), - }; - let range = match str_param(p, "range").map(str::trim).filter(|r| !r.is_empty()) { - Some(r) => Some(RangeRef::parse(split_sheet(r).1).ok_or_else(|| bad(cmd, format!("not a range: `{r}`")))?), - None => None, - }; - Ok(PrintOptions { all_sheets, range, fit_to_page: bool_param(p, "fitToPage") }) - } -} - -/// The range a sheet prints: its print area, else the used range grown to cover drawing -/// objects. `None` when there is nothing to print. -pub fn print_range(sh: &Sheet) -> Option { - if let Some(a) = sh.print.print_area { - return Some(trim_full(sh, a)); - } - let mut r = sh.used_range(); - let mut grow = |a: &Anchor| { - let x = sh.col_left(a.cell.col) + a.dx as f64; - let y = sh.row_top(a.cell.row) + a.dy as f64; - let end = CellRef::new(sh.row_at(y + a.height.max(0.0) as f64), sh.col_at(x + a.width.max(0.0) as f64)); - let ar = RangeRef::new(a.cell, end); - r = Some(r.map_or(ar, |u| u.union(&ar))); - }; - for c in &sh.charts { - grow(&c.anchor); - } - for i in &sh.images { - grow(&i.anchor); - } - for s in &sh.shapes { - grow(&s.anchor); - } - // Printing starts at A1 like Excel when the data starts nearby? Excel prints from the first - // used cell; keep the used range as is. - r -} - -/// Whole rows/columns are trimmed to the used range. -fn trim_full(sh: &Sheet, r: RangeRef) -> RangeRef { - if !(r.is_full_cols() || r.is_full_rows()) { - return r; - } - match sh.used_range() { - Some(u) => r.intersection(&RangeRef::new(CellRef::new(0, 0), u.end)).unwrap_or(RangeRef::cell(r.start)), - None => RangeRef::cell(r.start), - } -} - -/// Splits `start..=end` into bands whose sizes fit `avail(band_start)`; `breaks` force a new -/// band before a line. Stops after `cap` bands. -fn bands(start: u32, end: u32, size: impl Fn(u32) -> f32, avail: impl Fn(u32) -> f32, breaks: &[u32], cap: usize) -> Vec<(u32, u32)> { - let mut out = Vec::new(); - let mut cur = start; - let mut used = 0.0f32; - let mut room = avail(start); - let mut line = start; - loop { - let sz = size(line); - let manual = line > cur && breaks.binary_search(&line).is_ok(); - if line > cur && (manual || used + sz > room + 0.01) { - out.push((cur, line - 1)); - if out.len() >= cap { - return out; - } - cur = line; - used = 0.0; - room = avail(line); - } - used += sz; - if line >= end { - break; - } - line += 1; - } - out.push((cur, end)); - out -} - -fn title_span(t: Option<(u32, u32)>, max: u32) -> Option<(u32, u32)> { - let (a, b) = t?; - let (a, b) = (a.min(b), a.max(b)); - if a >= max { - return None; - } - // Repeated titles are capped (Excel needs them to fit on a page anyway). - Some((a, b.min(max - 1).min(a.saturating_add(499)))) -} - -/// Paginates one sheet. -pub fn layout_sheet(wb: &Workbook, si: usize, opts: &PrintOptions, range_override: Option) -> Option { - let sh = wb.sheet(si)?; - let st = sh.print.clone(); - let (mut pw, mut ph) = paper_size(&st.paper); - if st.orientation == Orientation::Landscape { - std::mem::swap(&mut pw, &mut ph); - } - let inch = |i: usize| st.margins.get(i).copied().filter(|v| v.is_finite()).unwrap_or(0.5).clamp(0.0, 20.0) * 72.0; - let mut margins = [inch(0), inch(1), inch(2), inch(3), inch(4), inch(5)]; - // Keep at least an inch of printable space. - for (a, b, total) in [(0usize, 1usize, pw), (2, 3, ph)] { - let sum = margins[a] + margins[b]; - if total - sum < 72.0 { - let f = ((total - 72.0).max(0.0)) / sum.max(1.0); - margins[a] *= f; - margins[b] *= f; - } - } - let area = match range_override { - Some(r) => trim_full(sh, r), - None => print_range(sh).unwrap_or(RangeRef::cell(CellRef::new(0, 0))), - }; - let title_rows = title_span(st.title_rows, MAX_ROWS); - let title_cols = title_span(st.title_cols, MAX_COLS); - let digits = format!("{}", area.end.row + 1).len() as f32; - let (heading_w, heading_h) = if st.headings { (10.0 + 7.0 * digits, sh.default_row_height.clamp(10.0, 60.0)) } else { (0.0, 0.0) }; - let fit = match opts.fit_to_page { - Some(true) => Some((1u16, 1u16)), - Some(false) => None, - None => st.fit_to.filter(|(w, h)| *w > 0 || *h > 0), - }; - let mut lay = SheetLayout { - sheet: si, - settings: st.clone(), - page_w: pw, - page_h: ph, - margins, - scale: (st.scale.clamp(10, 400) as f32) / 100.0, - area, - row_bands: vec![], - col_bands: vec![], - pages: vec![], - title_rows, - title_cols, - heading_w, - heading_h, - manual_breaks: fit.is_none(), - }; - let paginate = |lay: &mut SheetLayout, manual: bool| { - lay.manual_breaks = manual; - (lay.row_bands, lay.col_bands) = split_bands(sh, lay, MAX_PAGES); - lay.pages.clear(); - 'outer: for ci in 0..lay.col_bands.len() { - for ri in 0..lay.row_bands.len() { - if lay.pages.len() >= MAX_PAGES { - break 'outer; - } - lay.pages.push((ri, ci)); - } - } - }; - match fit { - Some((fw, fh)) => { - let total_w: f32 = (area.start.col..=area.end.col).map(|c| sh.col_width(c)).sum::() + heading_w; - let total_h: f32 = if area.height() > 200_000 { - f32::INFINITY - } else { - (area.start.row..=area.end.row).map(|r| sh.row_height(r)).sum::() + heading_h - }; - let (aw, ah) = lay.avail(); - let sw = if fw > 0 { aw * fw as f32 / (total_w * PX_TO_PT).max(1.0) } else { f32::INFINITY }; - let sh_ = if fh > 0 { ah * fh as f32 / (total_h * PX_TO_PT).max(1.0) } else { f32::INFINITY }; - lay.scale = sw.min(sh_).min(1.0).clamp(0.1, 1.0); - for _ in 0..40 { - paginate(&mut lay, false); - let ok_w = fw == 0 || lay.col_bands.len() <= fw as usize; - let ok_h = fh == 0 || lay.row_bands.len() <= fh as usize; - if (ok_w && ok_h) || lay.scale <= 0.1 { - break; - } - lay.scale = (lay.scale * 0.97).max(0.1); - } - } - None => paginate(&mut lay, true), - } - Some(lay) -} - -/// Splits a layout's area into row and column bands that fit its pages (at most `cap` of each), -/// honouring manual breaks (when `lay.manual_breaks`), print headings and repeated titles. The -/// one pagination rule shared by printing, PDF export, Page Break Preview and Page Layout view. -pub(crate) fn split_bands(sh: &Sheet, lay: &SheetLayout, cap: usize) -> (Vec<(u32, u32)>, Vec<(u32, u32)>) { - let k = lay.k(); - let (aw, ah) = lay.avail(); - let (aw, ah) = (aw / k, ah / k); - let st = &lay.settings; - let mut rb: Vec = if lay.manual_breaks { st.row_breaks.clone() } else { vec![] }; - let mut cb: Vec = if lay.manual_breaks { st.col_breaks.clone() } else { vec![] }; - rb.sort_unstable(); - cb.sort_unstable(); - let (title_rows, title_cols) = (lay.title_rows, lay.title_cols); - let title_h = |start: u32| -> f32 { - match title_rows { - Some((a, b)) if a < start => (a..=b.min(start - 1)).map(|r| sh.row_height(r)).sum(), - _ => 0.0, - } - }; - let title_w = |start: u32| -> f32 { - match title_cols { - Some((a, b)) if a < start => (a..=b.min(start - 1)).map(|c| sh.col_width(c)).sum(), - _ => 0.0, - } - }; - let area = lay.area; - let rows = bands(area.start.row, area.end.row, |r| sh.row_height(r), |start| (ah - lay.heading_h - title_h(start)).max(1.0), &rb, cap); - let cols = bands(area.start.col, area.end.col, |c| sh.col_width(c), |start| (aw - lay.heading_w - title_w(start)).max(1.0), &cb, cap); - (rows, cols) -} - -/// Lays out every sheet a print command covers. -pub fn layouts(wb: &Workbook, opts: &PrintOptions) -> Vec { - let mut out = Vec::new(); - let mut pages = 0usize; - let sheets: Vec = if opts.all_sheets { - (0..wb.sheets.len()).filter(|i| wb.sheet(*i).is_some_and(|s| s.visibility == Visibility::Visible)).collect() - } else { - vec![wb.active_sheet()] - }; - for si in sheets { - if pages >= MAX_PAGES { - break; - } - let ovr = if si == wb.active_sheet() { opts.range } else { None }; - if opts.all_sheets && ovr.is_none() && wb.sheet(si).is_some_and(|s| print_range(s).is_none()) { - continue; - } - if let Some(mut l) = layout_sheet(wb, si, opts, ovr) { - l.pages.truncate(MAX_PAGES - pages); - pages += l.pages.len(); - out.push(l); - } - } - if out.is_empty() && opts.all_sheets { - out.extend(layout_sheet(wb, wb.active_sheet(), opts, None)); - } - out -} - -// ---------------------------------------------------------------- header / footer - -/// Values for header/footer codes. -#[derive(Clone, Debug, Default)] -pub struct HfContext { - pub page: usize, - pub pages: usize, - pub sheet: String, - pub file: String, - pub path: String, - pub date: String, - pub time: String, -} - -/// Today's date and the current time as headers and footers show them (`&D`, `&T`). -pub fn hf_date_time(wb: &Workbook) -> (String, String) { - let now = gridcraft_core::date::serial_in_system(wb.date_system, gridcraft_calc::now_serial()); - let date = crate::display::format(&Value::Number(now.floor()), "m/d/yyyy", wb).text; - let time = crate::display::format(&Value::Number(now), "h:mm AM/PM", wb).text; - (date, time) -} - -/// Header/footer codes and the field names the Page Layout view shows for them while editing. -pub const HF_FIELDS: [(char, &str); 8] = [ - ('P', "&[Page]"), - ('N', "&[Pages]"), - ('D', "&[Date]"), - ('T', "&[Time]"), - ('Z', "&[Path]"), - ('F', "&[File]"), - ('A', "&[Tab]"), - ('G', "&[Picture]"), -]; - -/// Splits a header/footer string into its raw left, centre and right sections (codes kept). -/// Text before the first `&L`/`&C`/`&R` belongs to the centre, as when printing. -pub fn split_header_footer(spec: &str) -> [String; 3] { - let mut out: [String; 3] = Default::default(); - let mut cur = 1usize; - let chars: Vec = spec.chars().take(10_000).collect(); - let mut i = 0; - while let Some(&c) = chars.get(i) { - let next = chars.get(i + 1).copied(); - let sec = cur.min(2); - match (c, next) { - ('&', Some('L' | 'l')) => cur = 0, - ('&', Some('C' | 'c')) => cur = 1, - ('&', Some('R' | 'r')) => cur = 2, - ('&', Some('"')) => { - // &"Font,Style" is kept whole. - let end = chars[i + 2..].iter().position(|ch| *ch == '"').map_or(chars.len(), |p| i + 3 + p); - out[sec].extend(&chars[i..end.min(chars.len())]); - i = end; - continue; - } - ('&', Some(n)) => { - out[sec].push('&'); - out[sec].push(n); - } - ('&', None) => {} - (ch, _) => { - out[sec].push(ch); - i += 1; - continue; - } - } - i += 2; - } - out -} - -/// Joins raw left, centre and right sections into one header/footer string. -pub fn join_header_footer(sections: &[String; 3]) -> String { - let mut s = String::new(); - for (code, text) in ["&L", "&C", "&R"].iter().zip(sections) { - if !text.is_empty() { - s.push_str(code); - s.push_str(text); - } - } - s -} - -/// A raw section as the user edits it: `&P` shows as `&[Page]`, `&N` as `&[Pages]`, and so on. -pub fn codes_to_fields(raw: &str) -> String { - let mut out = String::new(); - let mut it = raw.chars().peekable(); - while let Some(c) = it.next() { - if c != '&' { - out.push(c); - continue; - } - // A dangling `&` at the end is dropped (printing ignores it too). - if let Some(n) = it.next() { - match HF_FIELDS.iter().find(|(code, _)| *code == n.to_ascii_uppercase()) { - Some((_, field)) => out.push_str(field), - None => { - out.push('&'); - out.push(n); - } - } - } - } - out -} - -/// The inverse of [`codes_to_fields`]: field names become codes again; a dangling `&` at the end -/// becomes a literal ampersand so it can't swallow the next section's code. -pub fn fields_to_codes(text: &str) -> String { - let chars: Vec = text.chars().take(10_000).collect(); - let mut out = String::new(); - let mut i = 0; - while let Some(&c) = chars.get(i) { - if c != '&' { - out.push(c); - i += 1; - continue; - } - if chars.get(i + 1) == Some(&'&') { - out.push_str("&&"); - i += 2; - continue; - } - let rest: String = chars[i..].iter().take(12).collect(); - if let Some((code, field)) = HF_FIELDS.iter().find(|(_, f)| rest.starts_with(f)) { - out.push('&'); - out.push(*code); - i += field.chars().count(); - continue; - } - if i + 1 == chars.len() { - out.push_str("&&"); - } else { - out.push('&'); - } - i += 1; - } - out -} - -/// One header/footer section: its text and whether `&B` made it bold. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct HfSection { - pub text: String, - pub bold: bool, - pub italic: bool, - pub size: Option, -} - -/// Expands a header/footer string into its left, centre and right sections. -pub fn expand_header_footer(spec: &str, cx: &HfContext) -> [HfSection; 3] { - let mut out: [HfSection; 3] = Default::default(); - let mut cur = 1usize; - let chars: Vec = spec.chars().take(10_000).collect(); - let mut i = 0; - while i < chars.len() { - let c = chars[i]; - if c != '&' { - if let Some(s) = out.get_mut(cur) { - s.text.push(c); - } - i += 1; - continue; - } - let Some(&code) = chars.get(i + 1) else { break }; - i += 2; - let sec = &mut out[cur.min(2)]; - match code { - 'L' | 'l' => cur = 0, - 'C' | 'c' => cur = 1, - 'R' | 'r' => cur = 2, - 'P' | 'p' => { - // &P, &P+n, &P-n - let mut n = cx.page as i64; - if let Some(&sign) = chars.get(i) - && (sign == '+' || sign == '-') - { - let digits: String = chars[i + 1..].iter().take_while(|d| d.is_ascii_digit()).take(6).collect(); - if let Ok(d) = digits.parse::() { - n = if sign == '+' { n + d } else { n - d }; - i += 1 + digits.len(); - } - } - sec.text.push_str(&n.to_string()); - } - 'N' | 'n' => sec.text.push_str(&cx.pages.to_string()), - 'D' | 'd' => sec.text.push_str(&cx.date), - 'T' | 't' => sec.text.push_str(&cx.time), - 'A' | 'a' => sec.text.push_str(&cx.sheet), - 'F' | 'f' => sec.text.push_str(&cx.file), - 'Z' | 'z' => sec.text.push_str(&cx.path), - '&' => sec.text.push('&'), - 'B' | 'b' => sec.bold = !sec.bold, - 'I' | 'i' => sec.italic = !sec.italic, - '"' => { - // &"Font,Style": skip the font name; honour Bold/Italic in the style. - let rest: String = chars[i..].iter().take_while(|c| **c != '"').collect(); - let lower = rest.to_ascii_lowercase(); - if lower.contains("bold") { - sec.bold = true; - } - if lower.contains("italic") || lower.contains("oblique") { - sec.italic = true; - } - i += rest.chars().count() + 1; - } - d if d.is_ascii_digit() => { - let digits: String = std::iter::once(d).chain(chars[i..].iter().copied().take_while(char::is_ascii_digit)).take(3).collect(); - i += digits.len() - 1; - if let Ok(v) = digits.parse::() { - sec.size = Some(v.clamp(1.0, 409.0)); - } - } - // Underline, strike, super/subscript, colour, picture: not rendered. - 'K' | 'k' => { - i = (i + 6).min(chars.len()); - } - _ => {} - } - } - out -} - -// ---------------------------------------------------------------- drawing - -fn rgb_of(c: Color, wb: &Workbook) -> Option { - c.resolve(&wb.theme) -} - -fn blend(fg: [u8; 4]) -> Rgb { - let a = fg[3] as u16; - let mix = |c: u8| ((c as u16 * a + 255 * (255 - a)) / 255) as u8; - [mix(fg[0]), mix(fg[1]), mix(fg[2])] -} - -/// The effective look of a cell: own style, table style, conditional formats. -struct Look { - style: Style, - fill: Option, - cf: Option, -} - -fn cell_look(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, cf: &mut Option) -> Look { - let mut style = match sh.table_at(c) { - Some(t) => crate::tables::table_cell_style(wb, si, t, c), - None => wb.styles.get(sh.style_id(c)).clone(), - }; - let cfl = cf.as_mut().and_then(|cache| cache.look(wb, si, c)); - if let Some(st) = cfl.as_ref().and_then(|l| l.style.as_ref()) { - if st.fill.pattern != PatternType::None { - style.fill = st.fill; - } - if st.font.color != Color::Auto { - style.font.color = st.font.color; - } - style.font.bold |= st.font.bold; - style.font.italic |= st.font.italic; - style.font.strike |= st.font.strike; - if st.font.underline != Underline::None { - style.font.underline = st.font.underline; - } - // In a differential style General means "not set" (#1163). - if st.num_fmt.as_str() != "General" { - style.num_fmt = st.num_fmt.clone(); - } - for (dst, src) in [ - (&mut style.borders.left, st.borders.left), - (&mut style.borders.right, st.borders.right), - (&mut style.borders.top, st.borders.top), - (&mut style.borders.bottom, st.borders.bottom), - ] { - if !src.is_none() { - *dst = src; - } - } - } - let mut fill = match style.fill.pattern { - PatternType::None => None, - PatternType::Solid => rgb_of(style.fill.fg, wb), - _ => { - let fg = rgb_of(style.fill.fg, wb).unwrap_or([0, 0, 0]); - let bg = rgb_of(style.fill.bg, wb).unwrap_or([255, 255, 255]); - Some([0, 1, 2].map(|i| ((fg[i] as u16 + bg[i] as u16 * 3) / 4) as u8)) - } - }; - if let Some(rgb) = cfl.as_ref().and_then(|l| l.scale_fill) { - fill = Some(rgb); - } - Look { style, fill, cf: cfl } -} - -/// PDF measurement for chart layout. -struct PdfMeasure; - -impl gridcraft_chart::Measure for PdfMeasure { - fn text_width(&self, text: &str, size: f32, bold: bool) -> f32 { - gridcraft_pdf::text_width(if bold { Font::HelveticaBold } else { Font::Helvetica }, size, text) - } -} - -/// Maps chart-space coordinates to the page. -#[derive(Clone, Copy)] -struct Xf { - ox: f32, - oy: f32, - k: f32, -} - -impl Xf { - fn p(&self, x: f32, y: f32) -> (f32, f32) { - (self.ox + x * self.k, self.oy + y * self.k) - } - fn pts(&self, pts: &[[f32; 2]]) -> Vec<[f32; 2]> { - pts.iter().map(|q| [self.ox + q[0] * self.k, self.oy + q[1] * self.k]).collect() - } -} - -/// Draws chart primitives onto a page. -fn draw_prims(pg: &mut Page, prims: &[gridcraft_chart::Prim], xf: Xf) { - use gridcraft_chart::{HAlign as H, Prim, VAlign as V}; - let k = xf.k; - for p in prims { - match p { - Prim::Rect { x, y, w, h, fill, stroke, .. } => { - let (px, py) = xf.p(*x, *y); - if let Some(f) = fill { - pg.fill_rect(px, py, w * k, h * k, blend(*f)); - } - if let Some((c, lw)) = stroke { - pg.rect_stroke(px, py, w * k, h * k, (lw * k).max(0.1), blend(*c)); - } - } - Prim::Line { pts, color, width, dash } => { - let d = if *dash { vec![3.0 * k, 2.0 * k] } else { vec![] }; - pg.polyline(&xf.pts(pts), (width * k).max(0.1), blend(*color), &d); - } - Prim::Polygon { pts, fill, stroke } => { - pg.polygon(&xf.pts(pts), Some(blend(*fill)), stroke.map(|(c, w)| (blend(c), (w * k).max(0.1)))); - } - Prim::Wedge { cx, cy, r_outer, r_inner, a0, a1, fill, stroke } => { - let span = (a1 - a0).abs().min(std::f32::consts::TAU); - let n = ((span / (std::f32::consts::PI / 36.0)).ceil() as usize).clamp(2, 400); - let at = |a: f32, r: f32| [cx + r * a.sin(), cy - r * a.cos()]; - let mut pts: Vec<[f32; 2]> = (0..=n).map(|i| at(a0 + (a1 - a0) * i as f32 / n as f32, *r_outer)).collect(); - if *r_inner > 0.0 { - pts.extend((0..=n).rev().map(|i| at(a0 + (a1 - a0) * i as f32 / n as f32, *r_inner))); - } else { - pts.push([*cx, *cy]); - } - pg.polygon(&xf.pts(&pts), Some(blend(*fill)), stroke.map(|(c, w)| (blend(c), (w * k).max(0.1)))); - } - Prim::Circle { cx, cy, r, fill, stroke } => { - let (px, py) = xf.p(*cx, *cy); - pg.circle(px, py, r * k, fill.map(blend), stroke.map(|(c, w)| (blend(c), (w * k).max(0.1)))); - } - Prim::Text { x, y, text, size, color, bold, align, valign, rotation } => { - let font = if *bold { Font::HelveticaBold } else { Font::Helvetica }; - let size = size * k; - let w = gridcraft_pdf::text_width(font, size, text); - let dx = match align { - H::Left => 0.0, - H::Center => -w / 2.0, - H::Right => -w, - }; - let dy = match valign { - V::Top => size * 0.78, - V::Middle => size * 0.35, - V::Bottom => -size * 0.21, - }; - let (sn, cs) = if rotation.is_finite() { rotation.sin_cos() } else { (0.0, 1.0) }; - let (ax, ay) = xf.p(*x, *y); - pg.text_rotated(ax + dx * cs - dy * sn, ay + dx * sn + dy * cs, size, font, blend(*color), text, *rotation); - } - } - } -} - -const PAD_PX: f32 = 2.0; - -/// Word-wraps `text` to `width` points. -fn wrap_lines(text: &str, font: Font, size: f32, width: f32) -> Vec { - let mut out = Vec::new(); - for para in text.split('\n') { - let mut line = String::new(); - for word in para.split(' ') { - let cand = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; - if line.is_empty() || gridcraft_pdf::text_width(font, size, &cand) <= width { - line = cand; - } else { - out.push(std::mem::take(&mut line)); - line = word.to_string(); - } - // Break a single over-long word by characters. The cut that fits is found by binary - // search (each try measures one candidate prefix), so an over-long word costs - // O(n log n) measurements instead of re-measuring the whole cut per character for - // every emitted line (`a` × 100k pasted text would be cubic worst case). - while gridcraft_pdf::text_width(font, size, &line) > width && line.chars().count() > 1 { - let chars: Vec = line.chars().collect(); - let (mut lo, mut hi) = (1usize, chars.len()); - while lo < hi { - let mid = lo + (hi - lo).div_ceil(2); - let cut: String = chars[..mid].iter().collect(); - if gridcraft_pdf::text_width(font, size, &cut) <= width { - lo = mid; - } else { - hi = mid - 1; - } - } - if lo == chars.len() { - break; - } - out.push(chars[..lo].iter().collect()); - line = chars[lo..].iter().collect(); - } - if out.len() > 2000 { - return out; - } - } - out.push(line); - } - out -} - -/// Everything needed to draw one page block (a run of rows × a run of columns). -struct Block<'a> { - wb: &'a Workbook, - si: usize, - sh: &'a Sheet, - rows: (u32, u32), - cols: (u32, u32), - /// Page x of each column's left edge, plus the right edge of the last. - xs: Vec, - ys: Vec, - k: f32, - scale: f32, -} - -impl Block<'_> { - fn x(&self, col: u32) -> f32 { - if col >= self.cols.0 && col <= self.cols.1 + 1 { - return self.xs.get((col - self.cols.0) as usize).copied().unwrap_or(0.0); - } - let x0 = self.xs.first().copied().unwrap_or(0.0); - x0 + ((self.sh.col_left(col) - self.sh.col_left(self.cols.0)) as f32) * self.k - } - fn y(&self, row: u32) -> f32 { - if row >= self.rows.0 && row <= self.rows.1 + 1 { - return self.ys.get((row - self.rows.0) as usize).copied().unwrap_or(0.0); - } - let y0 = self.ys.first().copied().unwrap_or(0.0); - y0 + ((self.sh.row_top(row) - self.sh.row_top(self.rows.0)) as f32) * self.k - } - fn rect(&self, r: RangeRef) -> (f32, f32, f32, f32) { - let (x0, y0) = (self.x(r.start.col), self.y(r.start.row)); - (x0, y0, self.x(r.end.col.saturating_add(1)) - x0, self.y(r.end.row.saturating_add(1)) - y0) - } - fn range(&self) -> RangeRef { - RangeRef::new(CellRef::new(self.rows.0, self.cols.0), CellRef::new(self.rows.1, self.cols.1)) - } -} - -fn border_dash(style: BorderStyle, k: f32) -> Vec { - let u = k.max(0.2); - match style { - BorderStyle::Dashed | BorderStyle::MediumDashed => vec![4.0 * u, 2.0 * u], - BorderStyle::Dotted => vec![1.0 * u, 1.0 * u], - BorderStyle::Hair => vec![0.75 * u, 0.75 * u], - BorderStyle::DashDot | BorderStyle::MediumDashDot | BorderStyle::SlantDashDot => vec![4.0 * u, 1.5 * u, 1.0 * u, 1.5 * u], - BorderStyle::DashDotDot | BorderStyle::MediumDashDotDot => vec![4.0 * u, 1.5 * u, 1.0 * u, 1.5 * u, 1.0 * u, 1.5 * u], - _ => vec![], - } -} - -fn draw_border(pg: &mut Page, b: &BorderLine, wb: &Workbook, x0: f32, y0: f32, x1: f32, y1: f32, k: f32) { - if b.is_none() { - return; - } - let color = rgb_of(b.color, wb).unwrap_or([0, 0, 0]); - if b.style == BorderStyle::Double { - let off = 1.0 * k; - let lw = (0.75 * k).max(0.2); - let (dx, dy) = if (y0 - y1).abs() < 0.001 { (0.0, off) } else { (off, 0.0) }; - pg.stroke_line(x0 - dx, y0 - dy, x1 - dx, y1 - dy, lw, color); - pg.stroke_line(x0 + dx, y0 + dy, x1 + dx, y1 + dy, lw, color); - return; - } - let lw = (b.style.width() * k).clamp(0.25, 6.0); - pg.stroke_line_dashed(x0, y0, x1, y1, lw, color, &border_dash(b.style, k)); -} - -fn font_for(style: &Style) -> Font { - Font::styled(Font::family_for(&style.font.name), style.font.bold, style.font.italic) -} - -/// Draws the cells of a block: fills, gridlines, data bars, borders and text. -fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Option) { - let (bx, by) = (b.xs.first().copied().unwrap_or(0.0), b.ys.first().copied().unwrap_or(0.0)); - let (bw, bh) = (b.xs.last().copied().unwrap_or(bx) - bx, b.ys.last().copied().unwrap_or(by) - by); - if bw <= 0.0 || bh <= 0.0 { - return; - } - let block = b.range(); - let sh = b.sh; - let merges: Vec = sh.merges.iter().filter(|m| m.intersects(&block)).copied().collect(); - let covered = |c: CellRef| merges.iter().any(|m| m.contains(c)); - // Cells worth visiting. - let cells: Vec = if block.count() <= DENSE_BLOCK { - block.iter().filter(|c| !covered(*c)).collect() - } else { - let mut set: Vec = sh.cells.iter_range(block).map(|(c, _)| c).collect(); - set.extend(sh.spill.range(block.start..=block.end).map(|(c, _)| *c).filter(|c| block.contains(*c))); - set.sort_unstable(); - set.dedup(); - set.retain(|c| !covered(*c)); - set - }; - pg.save(); - pg.clip_rect(bx, by, bw, bh); - if gridlines { - let grey = [0xB0, 0xB0, 0xB0]; - let lw = (0.35 * b.scale).clamp(0.1, 0.5); - for x in &b.xs { - pg.stroke_line(*x, by, *x, by + bh, lw, grey); - } - for y in &b.ys { - pg.stroke_line(bx, *y, bx + bw, *y, lw, grey); - } - } - // Cheap pre-filter: blank, unstyled cells outside tables and conditional formats draw nothing. - let skip = |c: &CellRef| { - sh.cond_formats.is_empty() - && sh.value_ref(*c).is_none_or(|v| v.is_empty()) - && sh.style_id(*c) == StyleId::DEFAULT - && (sh.tables.is_empty() || sh.table_at(*c).is_none()) - }; - let looks: Vec<(CellRef, Look)> = cells - .iter() - .filter(|c| !skip(c)) - .chain(merges.iter().map(|m| &m.start)) - .map(|c| (*c, cell_look(b.wb, b.si, sh, *c, cf))) - .filter(|(c, l)| { - l.fill.is_some() - || l.cf.is_some() - || merges.iter().any(|m| m.start == *c) - || !sh.value(*c).is_empty() - || l.style.borders != Borders::default() - }) - .collect(); - let rect_of = |c: CellRef| -> (f32, f32, f32, f32, bool) { - match merges.iter().find(|m| m.start == c) { - Some(m) => { - let (x, y, w, h) = b.rect(*m); - (x, y, w, h, true) - } - None => { - let (x, y, w, h) = b.rect(RangeRef::cell(c)); - (x, y, w, h, false) - } - } - }; - // Fills (merged blocks hide the gridlines inside them). - for (c, l) in &looks { - let (x, y, w, h, merged) = rect_of(*c); - if w <= 0.0 || h <= 0.0 { - continue; - } - if let Some(f) = l.fill { - pg.fill_rect(x, y, w, h, f); - } else if merged && gridlines { - pg.fill_rect(x, y, w, h, [255, 255, 255]); - } - if merged && gridlines { - pg.rect_stroke(x, y, w, h, (0.35 * b.scale).clamp(0.1, 0.5), [0xB0, 0xB0, 0xB0]); - } - if let Some((frac, rgb, _)) = l.cf.as_ref().and_then(|cf| cf.bar) { - let pad = 1.5 * b.k; - let bwid = ((w - 2.0 * pad) * frac.clamp(0.0, 1.0)).max(0.0); - pg.fill_rect(x + pad, y + pad, bwid, (h - 2.0 * pad).max(0.0), rgb); - } - } - // Borders. - for (c, l) in &looks { - let (x, y, w, h, merged) = rect_of(*c); - if w <= 0.0 || h <= 0.0 { - continue; - } - let bd = &l.style.borders; - let (right, bottom) = match merges.iter().find(|m| m.start == *c).filter(|_| merged) { - Some(m) => ( - b.wb.styles.get(sh.style_id(CellRef::new(m.start.row, m.end.col))).borders.right, - b.wb.styles.get(sh.style_id(CellRef::new(m.end.row, m.start.col))).borders.bottom, - ), - None => (bd.right, bd.bottom), - }; - draw_border(pg, &bd.top, b.wb, x, y, x + w, y, b.k); - draw_border(pg, &bottom, b.wb, x, y + h, x + w, y + h, b.k); - draw_border(pg, &bd.left, b.wb, x, y, x, y + h, b.k); - draw_border(pg, &right, b.wb, x + w, y, x + w, y + h, b.k); - draw_border(pg, &bd.diag_down, b.wb, x, y, x + w, y + h, b.k); - draw_border(pg, &bd.diag_up, b.wb, x, y + h, x + w, y, b.k); - } - // Text. - for (c, l) in &looks { - let (x, y, w, h, merged) = rect_of(*c); - if w <= 0.0 || h <= 0.0 { - continue; - } - if let Some(picture) = sh.cell_pictures.get(c) { - draw_cell_picture(pg, &picture.data, (x, y, w, h), b.k); - } else { - draw_text(pg, b, *c, l, (x, y, w, h), merged, &merges, (bx, bx + bw)); - } - } - pg.restore(); -} - -struct PendingPdfLink { - page: usize, - sheet: usize, - rect: (f32, f32, f32, f32), - target: String, -} - -struct PrintedBlock { - page: usize, - sheet: usize, - rows: (u32, u32), - cols: (u32, u32), - x: f32, - y: f32, - k: f32, -} - -/// PDF annotations are not affected by graphics clipping, so intersect each cell with its page -/// block before adding a clickable rectangle. A repeated print-title cell may appear on several -/// pages and receives one annotation on each of them. -fn draw_block_links(pg: &mut Page, b: &Block, page: usize, pending: &mut Vec) { - let range = b.range(); - let (Some(&bx), Some(&by), Some(&right), Some(&bottom)) = (b.xs.first(), b.ys.first(), b.xs.last(), b.ys.last()) else { return }; - for (cell, link) in b.sh.hyperlinks.range(range.start..=range.end) { - if !range.contains(*cell) { - continue; - } - let cell_range = b.sh.merges.iter().find(|m| m.start == *cell).copied().unwrap_or(RangeRef::cell(*cell)); - let (x, y, w, h) = b.rect(cell_range); - let (x0, y0, x1, y1) = (x.max(bx), y.max(by), (x + w).min(right), (y + h).min(bottom)); - if x1 <= x0 || y1 <= y0 { - continue; - } - let rect = (x0, y0, x1 - x0, y1 - y0); - match crate::links::classify(&link.target) { - crate::links::LinkKind::External(uri) => pg.link_uri(rect.0, rect.1, rect.2, rect.3, &uri), - crate::links::LinkKind::Internal(target) if !link.external => { - pending.push(PendingPdfLink { page, sheet: b.si, rect, target }); - } - _ => {} - } - } -} - -fn printed_target(wb: &Workbook, blocks: &[PrintedBlock], source_sheet: usize, target: &str) -> Option<(usize, f32, f32)> { - let target = target.trim().trim_start_matches('#').trim_start_matches('='); - let (sheet_name, range) = split_sheet(target); - let sheet = sheet_name.map_or(Some(source_sheet), |name| wb.sheet_index(&name))?; - let cell = RangeRef::parse(&range.replace('$', ""))?.start; - let sh = wb.sheet(sheet)?; - let block = blocks.iter().find(|b| b.sheet == sheet && (b.rows.0..=b.rows.1).contains(&cell.row) && (b.cols.0..=b.cols.1).contains(&cell.col))?; - let x = block.x + (sh.col_left(cell.col) - sh.col_left(block.cols.0)) as f32 * block.k; - let y = block.y + (sh.row_top(cell.row) - sh.row_top(block.rows.0)) as f32 * block.k; - Some((block.page, x, y)) -} - -/// 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)) -} - -/// Unscaled width of the empty Center Across Selection cells right of `c`, which its text is centred over too. -fn center_across_width(styles: &StyleTable, sh: &Sheet, c: CellRef, merges: &[RangeRef]) -> f32 { - let mut ext = 0.0; - let mut col = c.col + 1; - while col < MAX_COLS && ext < 20_000.0 { - let nc = CellRef::new(c.row, col); - if !empty_for_overflow(sh, nc, merges) || styles.get(sh.style_id(nc)).align.h != HAlign::CenterAcross { - break; - } - ext += sh.col_width(col); - col += 1; - } - ext -} - -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.and_then(gridcraft_numfmt::FormatColor::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 { - // The look's code, which a conditional format can override (#1163). - crate::display::value_text_as(wb, &v, code) - }; - if !wrap { - text = text.replace(['\n', '\r'], " "); - } - if text.is_empty() { - return; - } - let mut tw = gridcraft_pdf::text_width(font, size, &text); - if st.align.shrink && !wrap && tw > avail && tw > 0.0 { - size = (size * avail / tw).max(1.0); - tw = gridcraft_pdf::text_width(font, size, &text); - } - if is_num && !wrap && tw > avail + 0.01 && !sh.show_formulas { - let hash = gridcraft_pdf::text_width(font, size, "#").max(0.1); - text = "#".repeat(((avail / hash).floor().max(1.0) as usize).min(255)); - tw = gridcraft_pdf::text_width(font, size, &text); - } - let h_align = match st.align.h { - HAlign::General => { - if is_num { - HAlign::Right - } else if matches!(v, Value::Bool(_) | Value::Error(_)) { - HAlign::Center - } else { - HAlign::Left - } - } - HAlign::CenterAcross | HAlign::Distributed => HAlign::Center, - HAlign::Justify | HAlign::Fill => HAlign::Left, - other => other, - }; - let rot = st.align.rotation; - let rotated = rot != 0 && rot != 255; - // Rotated text wraps along its own direction: upright (±90°) text to the row height. - let lines: Vec = match (wrap, rotated) { - (true, false) => wrap_lines(&text, font, size, avail.max(size)), - (true, true) if rot.abs() == 90 => wrap_lines(&text, font, size, (h - 2.0 * pad).max(size)), - (true, true) => vec![text.lines().next().unwrap_or_default().to_string()], - (false, _) => vec![text], - }; - let line_h = size * 1.2; - let n = lines.len().max(1) as f32; - let first_baseline = match st.align.v { - VAlign::Top => y + pad + size * 0.8, - VAlign::Center | VAlign::Justify | VAlign::Distributed => y + (h - n * line_h) / 2.0 + line_h * 0.5 + size * 0.33, - VAlign::Bottom => y + h - pad - size * 0.22 - (n - 1.0) * line_h, - }; - // Overflow into empty neighbours (single-line text, not merged). - // Center Across Selection spans the empty Center Across cells to its right; the text stays within that run. - let spans = st.align.h == HAlign::CenterAcross && !wrap && !merged && rot == 0; - let across = if spans { center_across_width(&wb.styles, sh, c, merges) * b.k } else { 0.0 }; - let (mut clip_l, mut clip_r) = (x, x + w + across); - if !wrap && !merged && !is_num && rot.abs() != 90 && across <= 0.0 && tw + 2.0 * pad + indent > w { - let need = tw + 2.0 * pad + indent - w; - let grow = |dir: i64, mut need: f32| -> f32 { - let mut ext = 0.0; - let mut col = c.col as i64 + dir; - while need > 0.0 && col >= 0 && col < MAX_COLS as i64 { - let nc = CellRef::new(c.row, col as u32); - if !empty_for_overflow(sh, nc, merges) { - break; - } - let cw = sh.col_width(nc.col) * b.k; - ext += cw; - need -= cw; - col += dir; - if ext > 20_000.0 { - break; - } - } - ext - }; - match h_align { - HAlign::Right => clip_l -= grow(-1, need), - HAlign::Center => { - clip_l -= grow(-1, need / 2.0); - clip_r += grow(1, need / 2.0); - } - _ => clip_r += grow(1, need), - } - } - let clip_l = clip_l.max(limits.0); - let clip_r = clip_r.min(limits.1); - pg.save(); - pg.clip_rect(clip_l, y, (clip_r - clip_l).max(0.0), h); - if rotated { - // Rotated text: anchored at the bottom-left (upward) or top-left (downward) of the cell. - // Wrapped lines stack rightward for upward text and leftward for downward text, so - // downward text starts its first line far enough in for the last to end at the left pad. - let deg = (rot as f32).clamp(-90.0, 90.0); - let angle = -deg.to_radians(); - let last = lines.len().saturating_sub(1) as f32; - for (i, t) in lines.iter().enumerate() { - let i = i as f32; - let (ox, oy) = if deg > 0.0 { - (x + pad + size * 0.8 * deg.to_radians().sin() + i * line_h, y + h - pad) - } else { - (x + pad + (last - i) * line_h, y + pad + size * 0.8) - }; - pg.text_rotated(ox, oy, size, font, color, t, angle); - } - pg.restore(); - return; - } - if rot == 255 { - // Vertical stacked text. - let chars: Vec = lines.concat().chars().take(500).collect(); - let mut yy = y + pad + size * 0.8; - for ch in chars { - let s = ch.to_string(); - let cw = gridcraft_pdf::text_width(font, size, &s); - pg.text(x + (w - cw) / 2.0, yy, size, font, color, &s); - yy += line_h; - if yy > y + h + line_h { - break; - } - } - pg.restore(); - return; - } - for (i, line) in lines.iter().enumerate() { - let lw = gridcraft_pdf::text_width(font, size, line); - let lx = match h_align { - HAlign::Right => x + w - pad - lw - indent, - HAlign::Center => x + (w + across - lw) / 2.0, - _ => x + pad + indent, - }; - let ly = first_baseline + i as f32 * line_h; - if ly - size > y + h || ly < y - line_h { - continue; - } - pg.text(lx, ly, size, font, color, line); - if st.font.underline != Underline::None { - let uw = (size * 0.06).max(0.2); - pg.stroke_line(lx, ly + size * 0.12, lx + lw, ly + size * 0.12, uw, color); - if matches!(st.font.underline, Underline::Double | Underline::DoubleAccounting) { - pg.stroke_line(lx, ly + size * 0.24, lx + lw, ly + size * 0.24, uw, color); - } - } - if st.font.strike { - pg.stroke_line(lx, ly - size * 0.3, lx + lw, ly - size * 0.3, (size * 0.06).max(0.2), color); - } - } - pg.restore(); -} - -/// Sheet pixel rectangle of an anchor. -fn anchor_rect(sh: &Sheet, a: &Anchor) -> (f64, f64, f64, f64) { - (sh.col_left(a.cell.col) + a.dx as f64, sh.row_top(a.cell.row) + a.dy as f64, a.width.max(0.0) as f64, a.height.max(0.0) as f64) -} - -/// Decodes an embedded picture to RGBA, downscaled to at most `max_w`×`max_h` pixels. -fn decode_image(data: &[u8], max_w: u32, max_h: u32) -> Option<(Vec, u32, u32)> { - let (w, h) = crate::io::image_size(data)?; - if w == 0 || h == 0 || w as u64 * h as u64 > 60_000_000 { - return None; - } - let img = image::load_from_memory(data).ok()?; - let img = if img.width() > max_w.max(1) || img.height() > max_h.max(1) { img.thumbnail(max_w.max(1), max_h.max(1)) } else { img }; - let rgba = img.to_rgba8(); - let (pw, ph) = (rgba.width(), rgba.height()); - Some((rgba.into_raw(), pw, ph)) -} - -/// Draws charts, pictures, shapes and sparklines overlapping the body block of a page. -/// `pics` memoizes decoded pictures for the whole render (one page at a time draws each -/// picture again, but the decode happens once). -fn draw_objects( - pg: &mut Page, - wb: &Workbook, - si: usize, - sh: &Sheet, - b: &Block, - pics: &mut std::collections::HashMap<(usize, usize), Option<(Vec, u32, u32)>>, -) { - 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); - draw_sparklines(pg, wb, si, sh, b, block); - for (imi, im) in sh.images.iter().enumerate() { - 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); - let decoded = pics.entry((si, imi)).or_insert_with(|| decode_image(&im.data, mw, mh)); - if let Some((px, pw, ph)) = decoded.as_ref() { - 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); - draw_shape(pg, wb, shp, b, (x, y, w, h)); - } - 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_sparklines(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block, block: RangeRef) { - for sp in &sh.sparklines { - if !block.contains(sp.cell) { - continue; - } - let vals: Vec> = match gridcraft_calc::evaluate(wb, si, sp.cell, &sp.source) { - Value::Array(a) => a.iter().take(10_000).map(Value::as_f64).collect(), - v => vec![v.as_f64()], - }; - let color = rgb_of(sp.color, wb).unwrap_or([0x15, 0x60, 0x82]); - let (x, y, w, h) = b.rect(RangeRef::cell(sp.cell)); - let (wpx, hpx) = (w / b.k, h / b.k); - let prims = gridcraft_chart::render_sparkline(sp.kind, &vals, [color[0], color[1], color[2], 255], sp.markers, wpx, hpx); - draw_prims(pg, &prims, Xf { ox: x, oy: y, k: b.k }); - } -} - -fn draw_shape(pg: &mut Page, wb: &Workbook, shp: &gridcraft_model::Shape, b: &Block, (x, y, w, h): (f32, f32, f32, f32)) { - 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() { - return; - } - 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; - } -} - -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 [_, _, _, _, hm, fm] = lay.margins; - let (l, r) = lay.hf_sides(); - let text_scale = lay.hf_text_scale(); - 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) * text_scale; - let font = Font::styled(gridcraft_pdf::Family::Sans, sec.bold, sec.italic); - let lines: Vec<&str> = sec.text.split('\n').take(20).collect(); - let n = lines.len() as f32; - for (j, line) in lines.iter().enumerate() { - let lw = gridcraft_pdf::text_width(font, size, line); - let x = match i { - 0 => l, - 1 => (pw - lw) / 2.0, - _ => pw - r - lw, - }; - let y = if header { hm + size * 0.8 + j as f32 * size * 1.2 } else { ph - fm - size * 0.22 - (n - 1.0 - j as f32) * size * 1.2 }; - pg.text(x, y, size, font, [0, 0, 0], line); - } - } - } -} - -/// Builds a block for rows × cols positioned at (x, y) on the page. -fn make_block<'a>(wb: &'a Workbook, si: usize, sh: &'a Sheet, rows: (u32, u32), cols: (u32, u32), x: f32, y: f32, lay: &SheetLayout) -> Block<'a> { - let k = lay.k(); - let mut xs = Vec::with_capacity((cols.1 - cols.0 + 2) as usize); - let mut acc = x; - xs.push(acc); - for c in cols.0..=cols.1 { - acc += sh.col_width(c) * k; - xs.push(acc); - } - let mut ys = Vec::with_capacity((rows.1 - rows.0 + 2) as usize); - let mut acc = y; - ys.push(acc); - for r in rows.0..=rows.1 { - acc += sh.row_height(r) * k; - ys.push(acc); - } - Block { wb, si, sh, rows, cols, xs, ys, k, scale: lay.scale } -} - -/// Segments of a page along one axis: repeated titles (before the band) then the band. -fn segments(titles: Option<(u32, u32)>, band: (u32, u32)) -> Vec<(u32, u32)> { - let mut v = Vec::new(); - if let Some((a, b)) = titles - && a < band.0 - { - v.push((a, b.min(band.0 - 1))); - } - v.push(band); - v -} - -fn seg_size(sh: &Sheet, seg: (u32, u32), rows: bool) -> f32 { - if rows { (seg.0..=seg.1).map(|r| sh.row_height(r)).sum() } else { (seg.0..=seg.1).map(|c| sh.col_width(c)).sum() } -} /// Renders laid-out sheets to a PDF. pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str) -> (Vec, usize) { diff --git a/crates/engine/src/cmd/print/draw.rs b/crates/engine/src/cmd/print/draw.rs new file mode 100644 index 000000000..4f67f1449 --- /dev/null +++ b/crates/engine/src/cmd/print/draw.rs @@ -0,0 +1,467 @@ +use super::layout::{DENSE_BLOCK, SheetLayout}; +use super::objects::draw_cell_picture; +use super::text::draw_text; +use super::*; + +// ---------------------------------------------------------------- drawing + +pub(super) fn rgb_of(c: Color, wb: &Workbook) -> Option { + c.resolve(&wb.theme) +} + +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. +pub(super) struct Look { + pub(super) style: Style, + fill: Option, + pub(super) cf: Option, +} + +fn cell_look(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, cf: &mut Option) -> Look { + let mut style = match sh.table_at(c) { + Some(t) => crate::tables::table_cell_style(wb, si, t, c), + None => wb.styles.get(sh.style_id(c)).clone(), + }; + let cfl = cf.as_mut().and_then(|cache| cache.look(wb, si, c)); + if let Some(st) = cfl.as_ref().and_then(|l| l.style.as_ref()) { + if st.fill.pattern != PatternType::None { + style.fill = st.fill; + } + if st.font.color != Color::Auto { + style.font.color = st.font.color; + } + style.font.bold |= st.font.bold; + style.font.italic |= st.font.italic; + style.font.strike |= st.font.strike; + if st.font.underline != Underline::None { + style.font.underline = st.font.underline; + } + // In a differential style General means "not set" (#1163). + if st.num_fmt.as_str() != "General" { + style.num_fmt = st.num_fmt.clone(); + } + 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. +pub(super) 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)] +pub(super) struct Xf { + pub(super) ox: f32, + pub(super) oy: f32, + pub(super) 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. +pub(super) 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); + } + } + } +} + +/// Everything needed to draw one page block (a run of rows × a run of columns). +pub(super) struct Block<'a> { + pub(super) wb: &'a Workbook, + si: usize, + pub(super) sh: &'a Sheet, + rows: (u32, u32), + cols: (u32, u32), + /// Page x of each column's left edge, plus the right edge of the last. + pub(super) xs: Vec, + pub(super) ys: Vec, + pub(super) k: f32, + pub(super) 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 + } + pub(super) 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) + } + pub(super) fn range(&self) -> RangeRef { + RangeRef::new(CellRef::new(self.rows.0, self.cols.0), CellRef::new(self.rows.1, self.cols.1)) + } +} + +fn border_dash(style: BorderStyle, k: f32) -> Vec { + let u = k.max(0.2); + match style { + BorderStyle::Dashed | BorderStyle::MediumDashed => vec![4.0 * u, 2.0 * u], + BorderStyle::Dotted => vec![1.0 * u, 1.0 * u], + BorderStyle::Hair => vec![0.75 * u, 0.75 * u], + BorderStyle::DashDot | BorderStyle::MediumDashDot | BorderStyle::SlantDashDot => vec![4.0 * u, 1.5 * u, 1.0 * u, 1.5 * u], + BorderStyle::DashDotDot | BorderStyle::MediumDashDotDot => vec![4.0 * u, 1.5 * u, 1.0 * u, 1.5 * u, 1.0 * u, 1.5 * u], + _ => vec![], + } +} + +fn draw_border(pg: &mut Page, b: &BorderLine, wb: &Workbook, x0: f32, y0: f32, x1: f32, y1: f32, k: f32) { + if b.is_none() { + return; + } + let color = rgb_of(b.color, wb).unwrap_or([0, 0, 0]); + if b.style == BorderStyle::Double { + let off = 1.0 * k; + let lw = (0.75 * k).max(0.2); + let (dx, dy) = if (y0 - y1).abs() < 0.001 { (0.0, off) } else { (off, 0.0) }; + pg.stroke_line(x0 - dx, y0 - dy, x1 - dx, y1 - dy, lw, color); + pg.stroke_line(x0 + dx, y0 + dy, x1 + dx, y1 + dy, lw, color); + return; + } + let lw = (b.style.width() * k).clamp(0.25, 6.0); + pg.stroke_line_dashed(x0, y0, x1, y1, lw, color, &border_dash(b.style, k)); +} + +/// Draws the cells of a block: fills, gridlines, data bars, borders and text. +pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Option) { + let (bx, by) = (b.xs.first().copied().unwrap_or(0.0), b.ys.first().copied().unwrap_or(0.0)); + let (bw, bh) = (b.xs.last().copied().unwrap_or(bx) - bx, b.ys.last().copied().unwrap_or(by) - by); + if bw <= 0.0 || bh <= 0.0 { + return; + } + let block = b.range(); + let sh = b.sh; + let merges: Vec = sh.merges.iter().filter(|m| m.intersects(&block)).copied().collect(); + let covered = |c: CellRef| merges.iter().any(|m| m.contains(c)); + // Cells worth visiting. + let cells: Vec = if block.count() <= DENSE_BLOCK { + block.iter().filter(|c| !covered(*c)).collect() + } else { + let mut set: Vec = sh.cells.iter_range(block).map(|(c, _)| c).collect(); + set.extend(sh.spill.range(block.start..=block.end).map(|(c, _)| *c).filter(|c| block.contains(*c))); + set.sort_unstable(); + set.dedup(); + set.retain(|c| !covered(*c)); + set + }; + pg.save(); + pg.clip_rect(bx, by, bw, bh); + if gridlines { + let grey = [0xB0, 0xB0, 0xB0]; + let lw = (0.35 * b.scale).clamp(0.1, 0.5); + for x in &b.xs { + pg.stroke_line(*x, by, *x, by + bh, lw, grey); + } + for y in &b.ys { + pg.stroke_line(bx, *y, bx + bw, *y, lw, grey); + } + } + // Cheap pre-filter: blank, unstyled cells outside tables and conditional formats draw nothing. + let skip = |c: &CellRef| { + sh.cond_formats.is_empty() + && sh.value_ref(*c).is_none_or(|v| v.is_empty()) + && sh.style_id(*c) == StyleId::DEFAULT + && (sh.tables.is_empty() || sh.table_at(*c).is_none()) + }; + let looks: Vec<(CellRef, Look)> = cells + .iter() + .filter(|c| !skip(c)) + .chain(merges.iter().map(|m| &m.start)) + .map(|c| (*c, cell_look(b.wb, b.si, sh, *c, cf))) + .filter(|(c, l)| { + l.fill.is_some() + || l.cf.is_some() + || merges.iter().any(|m| m.start == *c) + || !sh.value(*c).is_empty() + || l.style.borders != Borders::default() + }) + .collect(); + let rect_of = |c: CellRef| -> (f32, f32, f32, f32, bool) { + match merges.iter().find(|m| m.start == c) { + Some(m) => { + let (x, y, w, h) = b.rect(*m); + (x, y, w, h, true) + } + None => { + let (x, y, w, h) = b.rect(RangeRef::cell(c)); + (x, y, w, h, false) + } + } + }; + // Fills (merged blocks hide the gridlines inside them). + for (c, l) in &looks { + let (x, y, w, h, merged) = rect_of(*c); + if w <= 0.0 || h <= 0.0 { + continue; + } + if let Some(f) = l.fill { + pg.fill_rect(x, y, w, h, f); + } else if merged && gridlines { + pg.fill_rect(x, y, w, h, [255, 255, 255]); + } + if merged && gridlines { + pg.rect_stroke(x, y, w, h, (0.35 * b.scale).clamp(0.1, 0.5), [0xB0, 0xB0, 0xB0]); + } + if let Some((frac, rgb, _)) = l.cf.as_ref().and_then(|cf| cf.bar) { + let pad = 1.5 * b.k; + let bwid = ((w - 2.0 * pad) * frac.clamp(0.0, 1.0)).max(0.0); + pg.fill_rect(x + pad, y + pad, bwid, (h - 2.0 * pad).max(0.0), rgb); + } + } + // Borders. + for (c, l) in &looks { + let (x, y, w, h, merged) = rect_of(*c); + if w <= 0.0 || h <= 0.0 { + continue; + } + let bd = &l.style.borders; + let (right, bottom) = match merges.iter().find(|m| m.start == *c).filter(|_| merged) { + Some(m) => ( + b.wb.styles.get(sh.style_id(CellRef::new(m.start.row, m.end.col))).borders.right, + b.wb.styles.get(sh.style_id(CellRef::new(m.end.row, m.start.col))).borders.bottom, + ), + None => (bd.right, bd.bottom), + }; + draw_border(pg, &bd.top, b.wb, x, y, x + w, y, b.k); + draw_border(pg, &bottom, b.wb, x, y + h, x + w, y + h, b.k); + draw_border(pg, &bd.left, b.wb, x, y, x, y + h, b.k); + draw_border(pg, &right, b.wb, x + w, y, x + w, y + h, b.k); + draw_border(pg, &bd.diag_down, b.wb, x, y, x + w, y + h, b.k); + draw_border(pg, &bd.diag_up, b.wb, x, y + h, x + w, y, b.k); + } + // Text. + for (c, l) in &looks { + let (x, y, w, h, merged) = rect_of(*c); + if w <= 0.0 || h <= 0.0 { + continue; + } + if let Some(picture) = sh.cell_pictures.get(c) { + draw_cell_picture(pg, &picture.data, (x, y, w, h), b.k); + } else { + draw_text(pg, b, *c, l, (x, y, w, h), merged, &merges, (bx, bx + bw)); + } + } + pg.restore(); +} + +pub(super) struct PendingPdfLink { + pub(super) page: usize, + pub(super) sheet: usize, + pub(super) rect: (f32, f32, f32, f32), + pub(super) target: String, +} + +pub(super) struct PrintedBlock { + pub(super) page: usize, + pub(super) sheet: usize, + pub(super) rows: (u32, u32), + pub(super) cols: (u32, u32), + pub(super) x: f32, + pub(super) y: f32, + pub(super) k: f32, +} + +/// PDF annotations are not affected by graphics clipping, so intersect each cell with its page +/// block before adding a clickable rectangle. A repeated print-title cell may appear on several +/// pages and receives one annotation on each of them. +pub(super) fn draw_block_links(pg: &mut Page, b: &Block, page: usize, pending: &mut Vec) { + let range = b.range(); + let (Some(&bx), Some(&by), Some(&right), Some(&bottom)) = (b.xs.first(), b.ys.first(), b.xs.last(), b.ys.last()) else { return }; + for (cell, link) in b.sh.hyperlinks.range(range.start..=range.end) { + if !range.contains(*cell) { + continue; + } + let cell_range = b.sh.merges.iter().find(|m| m.start == *cell).copied().unwrap_or(RangeRef::cell(*cell)); + let (x, y, w, h) = b.rect(cell_range); + let (x0, y0, x1, y1) = (x.max(bx), y.max(by), (x + w).min(right), (y + h).min(bottom)); + if x1 <= x0 || y1 <= y0 { + continue; + } + let rect = (x0, y0, x1 - x0, y1 - y0); + match crate::links::classify(&link.target) { + crate::links::LinkKind::External(uri) => pg.link_uri(rect.0, rect.1, rect.2, rect.3, &uri), + crate::links::LinkKind::Internal(target) if !link.external => { + pending.push(PendingPdfLink { page, sheet: b.si, rect, target }); + } + _ => {} + } + } +} + +pub(super) fn printed_target(wb: &Workbook, blocks: &[PrintedBlock], source_sheet: usize, target: &str) -> Option<(usize, f32, f32)> { + let target = target.trim().trim_start_matches('#').trim_start_matches('='); + let (sheet_name, range) = split_sheet(target); + let sheet = sheet_name.map_or(Some(source_sheet), |name| wb.sheet_index(&name))?; + let cell = RangeRef::parse(&range.replace('$', ""))?.start; + let sh = wb.sheet(sheet)?; + let block = blocks.iter().find(|b| b.sheet == sheet && (b.rows.0..=b.rows.1).contains(&cell.row) && (b.cols.0..=b.cols.1).contains(&cell.col))?; + let x = block.x + (sh.col_left(cell.col) - sh.col_left(block.cols.0)) as f32 * block.k; + let y = block.y + (sh.row_top(cell.row) - sh.row_top(block.rows.0)) as f32 * block.k; + Some((block.page, x, y)) +} + +pub(super) 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); + } + } +} + +/// Builds a block for rows × cols positioned at (x, y) on the page. +pub(super) 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 } +} diff --git a/crates/engine/src/cmd/print/header_footer.rs b/crates/engine/src/cmd/print/header_footer.rs new file mode 100644 index 000000000..769e3b3ad --- /dev/null +++ b/crates/engine/src/cmd/print/header_footer.rs @@ -0,0 +1,261 @@ +use super::layout::SheetLayout; +use super::*; + +/// Side margin of headers and footers, in points, when Align with Page Margins is off. +pub(super) const HF_FIXED_SIDE_MARGIN: f32 = 0.7 * 72.0; + +// ---------------------------------------------------------------- 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_core::date::serial_in_system(wb.date_system, gridcraft_calc::now_serial()); + let date = crate::display::format(&Value::Number(now.floor()), "m/d/yyyy", wb).text; + let time = crate::display::format(&Value::Number(now), "h:mm AM/PM", wb).text; + (date, time) +} + +/// Header/footer codes and the field names the Page Layout view shows for them while editing. +pub const HF_FIELDS: [(char, &str); 8] = [ + ('P', "&[Page]"), + ('N', "&[Pages]"), + ('D', "&[Date]"), + ('T', "&[Time]"), + ('Z', "&[Path]"), + ('F', "&[File]"), + ('A', "&[Tab]"), + ('G', "&[Picture]"), +]; + +/// Splits a header/footer string into its raw left, centre and right sections (codes kept). +/// Text before the first `&L`/`&C`/`&R` belongs to the centre, as when printing. +pub fn split_header_footer(spec: &str) -> [String; 3] { + let mut out: [String; 3] = Default::default(); + let mut cur = 1usize; + let chars: Vec = spec.chars().take(10_000).collect(); + let mut i = 0; + while let Some(&c) = chars.get(i) { + let next = chars.get(i + 1).copied(); + let sec = cur.min(2); + match (c, next) { + ('&', Some('L' | 'l')) => cur = 0, + ('&', Some('C' | 'c')) => cur = 1, + ('&', Some('R' | 'r')) => cur = 2, + ('&', Some('"')) => { + // &"Font,Style" is kept whole. + let end = chars[i + 2..].iter().position(|ch| *ch == '"').map_or(chars.len(), |p| i + 3 + p); + out[sec].extend(&chars[i..end.min(chars.len())]); + i = end; + continue; + } + ('&', Some(n)) => { + out[sec].push('&'); + out[sec].push(n); + } + ('&', None) => {} + (ch, _) => { + out[sec].push(ch); + i += 1; + continue; + } + } + i += 2; + } + out +} + +/// Joins raw left, centre and right sections into one header/footer string. +pub fn join_header_footer(sections: &[String; 3]) -> String { + let mut s = String::new(); + for (code, text) in ["&L", "&C", "&R"].iter().zip(sections) { + if !text.is_empty() { + s.push_str(code); + s.push_str(text); + } + } + s +} + +/// A raw section as the user edits it: `&P` shows as `&[Page]`, `&N` as `&[Pages]`, and so on. +pub fn codes_to_fields(raw: &str) -> String { + let mut out = String::new(); + let mut it = raw.chars().peekable(); + while let Some(c) = it.next() { + if c != '&' { + out.push(c); + continue; + } + // A dangling `&` at the end is dropped (printing ignores it too). + if let Some(n) = it.next() { + match HF_FIELDS.iter().find(|(code, _)| *code == n.to_ascii_uppercase()) { + Some((_, field)) => out.push_str(field), + None => { + out.push('&'); + out.push(n); + } + } + } + } + out +} + +/// The inverse of [`codes_to_fields`]: field names become codes again; a dangling `&` at the end +/// becomes a literal ampersand so it can't swallow the next section's code. +pub fn fields_to_codes(text: &str) -> String { + let chars: Vec = text.chars().take(10_000).collect(); + let mut out = String::new(); + let mut i = 0; + while let Some(&c) = chars.get(i) { + if c != '&' { + out.push(c); + i += 1; + continue; + } + if chars.get(i + 1) == Some(&'&') { + out.push_str("&&"); + i += 2; + continue; + } + let rest: String = chars[i..].iter().take(12).collect(); + if let Some((code, field)) = HF_FIELDS.iter().find(|(_, f)| rest.starts_with(f)) { + out.push('&'); + out.push(*code); + i += field.chars().count(); + continue; + } + if i + 1 == chars.len() { + out.push_str("&&"); + } else { + out.push('&'); + } + i += 1; + } + out +} + +/// One header/footer section: its text and whether `&B` made it bold. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct HfSection { + pub text: String, + pub bold: bool, + pub italic: bool, + pub size: Option, +} + +/// Expands a header/footer string into its left, centre and right sections. +pub fn expand_header_footer(spec: &str, cx: &HfContext) -> [HfSection; 3] { + let mut out: [HfSection; 3] = Default::default(); + let mut cur = 1usize; + let chars: Vec = spec.chars().take(10_000).collect(); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + if c != '&' { + if let Some(s) = out.get_mut(cur) { + s.text.push(c); + } + i += 1; + continue; + } + let Some(&code) = chars.get(i + 1) else { break }; + i += 2; + let sec = &mut out[cur.min(2)]; + match code { + 'L' | 'l' => cur = 0, + 'C' | 'c' => cur = 1, + 'R' | 'r' => cur = 2, + 'P' | 'p' => { + // &P, &P+n, &P-n + let mut n = cx.page as i64; + if let Some(&sign) = chars.get(i) + && (sign == '+' || sign == '-') + { + let digits: String = chars[i + 1..].iter().take_while(|d| d.is_ascii_digit()).take(6).collect(); + if let Ok(d) = digits.parse::() { + n = if sign == '+' { n + d } else { n - d }; + i += 1 + digits.len(); + } + } + sec.text.push_str(&n.to_string()); + } + 'N' | 'n' => sec.text.push_str(&cx.pages.to_string()), + 'D' | 'd' => sec.text.push_str(&cx.date), + 'T' | 't' => sec.text.push_str(&cx.time), + 'A' | 'a' => sec.text.push_str(&cx.sheet), + 'F' | 'f' => sec.text.push_str(&cx.file), + 'Z' | 'z' => sec.text.push_str(&cx.path), + '&' => sec.text.push('&'), + 'B' | 'b' => sec.bold = !sec.bold, + 'I' | 'i' => sec.italic = !sec.italic, + '"' => { + // &"Font,Style": skip the font name; honour Bold/Italic in the style. + let rest: String = chars[i..].iter().take_while(|c| **c != '"').collect(); + let lower = rest.to_ascii_lowercase(); + if lower.contains("bold") { + sec.bold = true; + } + if lower.contains("italic") || lower.contains("oblique") { + sec.italic = true; + } + i += rest.chars().count() + 1; + } + d if d.is_ascii_digit() => { + let digits: String = std::iter::once(d).chain(chars[i..].iter().copied().take_while(char::is_ascii_digit)).take(3).collect(); + i += digits.len() - 1; + if let Ok(v) = digits.parse::() { + sec.size = Some(v.clamp(1.0, 409.0)); + } + } + // Underline, strike, super/subscript, colour, picture: not rendered. + 'K' | 'k' => { + i = (i + 6).min(chars.len()); + } + _ => {} + } + } + out +} + +pub(super) fn draw_header_footer(pg: &mut Page, lay: &SheetLayout, which: gridcraft_model::HfPage, cx: &HfContext) { + let [_, _, _, _, hm, fm] = lay.margins; + let (l, r) = lay.hf_sides(); + let text_scale = lay.hf_text_scale(); + 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) * text_scale; + 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); + } + } + } +} diff --git a/crates/engine/src/cmd/print/layout.rs b/crates/engine/src/cmd/print/layout.rs new file mode 100644 index 000000000..55e874de7 --- /dev/null +++ b/crates/engine/src/cmd/print/layout.rs @@ -0,0 +1,364 @@ +use super::header_footer::HF_FIXED_SIDE_MARGIN; +use super::*; + +/// 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, + pub fit_to_page: Option, +} + +impl PrintOptions { + pub(super) fn from_params(p: &Json, cmd: &str) -> Result { + let all_sheets = match str_param(p, "sheets") { + None | Some("active") => false, + Some("all") | Some("workbook") => true, + Some(other) => return Err(bad(cmd, format!("`sheets` must be active or all, not `{other}`"))), + }; + let range = match str_param(p, "range").map(str::trim).filter(|r| !r.is_empty()) { + Some(r) => Some(RangeRef::parse(split_sheet(r).1).ok_or_else(|| bad(cmd, format!("not a range: `{r}`")))?), + None => None, + }; + Ok(PrintOptions { all_sheets, range, fit_to_page: bool_param(p, "fitToPage") }) + } +} + +/// The range a sheet prints: its print area, else the used range grown to cover drawing +/// objects. `None` when there is nothing to print. +pub fn print_range(sh: &Sheet) -> Option { + if let Some(a) = sh.print.print_area { + return Some(trim_full(sh, a)); + } + let mut r = sh.used_range(); + let mut grow = |a: &Anchor| { + let x = sh.col_left(a.cell.col) + a.dx as f64; + let y = sh.row_top(a.cell.row) + a.dy as f64; + let end = CellRef::new(sh.row_at(y + a.height.max(0.0) as f64), sh.col_at(x + a.width.max(0.0) as f64)); + let ar = RangeRef::new(a.cell, end); + r = Some(r.map_or(ar, |u| u.union(&ar))); + }; + for c in &sh.charts { + grow(&c.anchor); + } + for i in &sh.images { + grow(&i.anchor); + } + for s in &sh.shapes { + grow(&s.anchor); + } + // Printing starts at A1 like Excel when the data starts nearby? Excel prints from the first + // used cell; keep the used range as is. + r +} + +/// Whole rows/columns are trimmed to the used range. +fn trim_full(sh: &Sheet, r: RangeRef) -> RangeRef { + if !(r.is_full_cols() || r.is_full_rows()) { + return r; + } + match sh.used_range() { + Some(u) => r.intersection(&RangeRef::new(CellRef::new(0, 0), u.end)).unwrap_or(RangeRef::cell(r.start)), + None => RangeRef::cell(r.start), + } +} + +/// Splits `start..=end` into bands whose sizes fit `avail(band_start)`; `breaks` force a new +/// band before a line. Stops after `cap` bands. +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) -> Option { + let sh = wb.sheet(si)?; + let st = sh.print.clone(); + let (mut pw, mut ph) = paper_size(&st.paper); + if st.orientation == Orientation::Landscape { + std::mem::swap(&mut pw, &mut ph); + } + let inch = |i: usize| st.margins.get(i).copied().filter(|v| v.is_finite()).unwrap_or(0.5).clamp(0.0, 20.0) * 72.0; + let mut margins = [inch(0), inch(1), inch(2), inch(3), inch(4), inch(5)]; + // Keep at least an inch of printable space. + for (a, b, total) in [(0usize, 1usize, pw), (2, 3, ph)] { + let sum = margins[a] + margins[b]; + if total - sum < 72.0 { + let f = ((total - 72.0).max(0.0)) / sum.max(1.0); + margins[a] *= f; + margins[b] *= f; + } + } + let area = match range_override { + Some(r) => trim_full(sh, r), + None => print_range(sh).unwrap_or(RangeRef::cell(CellRef::new(0, 0))), + }; + let title_rows = title_span(st.title_rows, MAX_ROWS); + let title_cols = title_span(st.title_cols, MAX_COLS); + let digits = format!("{}", area.end.row + 1).len() as f32; + let (heading_w, heading_h) = if st.headings { (10.0 + 7.0 * digits, sh.default_row_height.clamp(10.0, 60.0)) } else { (0.0, 0.0) }; + let fit = match opts.fit_to_page { + Some(true) => Some((1u16, 1u16)), + Some(false) => None, + None => st.fit_to.filter(|(w, h)| *w > 0 || *h > 0), + }; + let mut lay = SheetLayout { + sheet: si, + settings: st.clone(), + page_w: pw, + page_h: ph, + margins, + scale: (st.scale.clamp(10, 400) as f32) / 100.0, + area, + row_bands: vec![], + col_bands: vec![], + pages: vec![], + title_rows, + title_cols, + heading_w, + heading_h, + manual_breaks: fit.is_none(), + }; + let paginate = |lay: &mut SheetLayout, manual: bool| { + lay.manual_breaks = manual; + (lay.row_bands, lay.col_bands) = split_bands(sh, lay, MAX_PAGES); + lay.pages.clear(); + 'outer: for ci in 0..lay.col_bands.len() { + for ri in 0..lay.row_bands.len() { + if lay.pages.len() >= MAX_PAGES { + break 'outer; + } + lay.pages.push((ri, ci)); + } + } + }; + match fit { + Some((fw, fh)) => { + let total_w: f32 = (area.start.col..=area.end.col).map(|c| sh.col_width(c)).sum::() + heading_w; + let total_h: f32 = if area.height() > 200_000 { + f32::INFINITY + } else { + (area.start.row..=area.end.row).map(|r| sh.row_height(r)).sum::() + heading_h + }; + let (aw, ah) = lay.avail(); + let sw = if fw > 0 { aw * fw as f32 / (total_w * PX_TO_PT).max(1.0) } else { f32::INFINITY }; + let sh_ = if fh > 0 { ah * fh as f32 / (total_h * PX_TO_PT).max(1.0) } else { f32::INFINITY }; + lay.scale = sw.min(sh_).min(1.0).clamp(0.1, 1.0); + for _ in 0..40 { + paginate(&mut lay, false); + let ok_w = fw == 0 || lay.col_bands.len() <= fw as usize; + let ok_h = fh == 0 || lay.row_bands.len() <= fh as usize; + if (ok_w && ok_h) || lay.scale <= 0.1 { + break; + } + lay.scale = (lay.scale * 0.97).max(0.1); + } + } + None => paginate(&mut lay, true), + } + Some(lay) +} + +/// Splits a layout's area into row and column bands that fit its pages (at most `cap` of each), +/// honouring manual breaks (when `lay.manual_breaks`), print headings and repeated titles. The +/// one pagination rule shared by printing, PDF export, Page Break Preview and Page Layout view. +pub(crate) fn split_bands(sh: &Sheet, lay: &SheetLayout, cap: usize) -> (Vec<(u32, u32)>, Vec<(u32, u32)>) { + let k = lay.k(); + let (aw, ah) = lay.avail(); + let (aw, ah) = (aw / k, ah / k); + let st = &lay.settings; + let mut rb: Vec = if lay.manual_breaks { st.row_breaks.clone() } else { vec![] }; + let mut cb: Vec = if lay.manual_breaks { st.col_breaks.clone() } else { vec![] }; + rb.sort_unstable(); + cb.sort_unstable(); + let (title_rows, title_cols) = (lay.title_rows, lay.title_cols); + let title_h = |start: u32| -> f32 { + match title_rows { + Some((a, b)) if a < start => (a..=b.min(start - 1)).map(|r| sh.row_height(r)).sum(), + _ => 0.0, + } + }; + let title_w = |start: u32| -> f32 { + match title_cols { + Some((a, b)) if a < start => (a..=b.min(start - 1)).map(|c| sh.col_width(c)).sum(), + _ => 0.0, + } + }; + let area = lay.area; + let rows = bands(area.start.row, area.end.row, |r| sh.row_height(r), |start| (ah - lay.heading_h - title_h(start)).max(1.0), &rb, cap); + let cols = bands(area.start.col, area.end.col, |c| sh.col_width(c), |start| (aw - lay.heading_w - title_w(start)).max(1.0), &cb, cap); + (rows, cols) +} + +/// Lays out every sheet a print command covers. +pub fn layouts(wb: &Workbook, opts: &PrintOptions) -> Vec { + let mut out = Vec::new(); + let mut pages = 0usize; + let sheets: Vec = if opts.all_sheets { + (0..wb.sheets.len()).filter(|i| wb.sheet(*i).is_some_and(|s| s.visibility == Visibility::Visible)).collect() + } else { + vec![wb.active_sheet()] + }; + for si in sheets { + if pages >= MAX_PAGES { + break; + } + let ovr = if si == wb.active_sheet() { opts.range } else { None }; + if opts.all_sheets && ovr.is_none() && wb.sheet(si).is_some_and(|s| print_range(s).is_none()) { + continue; + } + if let Some(mut l) = layout_sheet(wb, si, opts, ovr) { + l.pages.truncate(MAX_PAGES - pages); + pages += l.pages.len(); + out.push(l); + } + } + if out.is_empty() && opts.all_sheets { + out.extend(layout_sheet(wb, wb.active_sheet(), opts, None)); + } + out +} + +/// 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() } +} diff --git a/crates/engine/src/cmd/print/objects.rs b/crates/engine/src/cmd/print/objects.rs new file mode 100644 index 000000000..83044836e --- /dev/null +++ b/crates/engine/src/cmd/print/objects.rs @@ -0,0 +1,177 @@ +use super::draw::{Block, PdfMeasure, Xf, draw_prims, rgb_of}; +use super::text::wrap_lines; +use super::*; + +/// Cell pictures fit the current cell (or merged-cell) rectangle without changing aspect ratio. +pub(super) 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()); +} + +/// Sheet pixel rectangle of an anchor. +fn anchor_rect(sh: &Sheet, a: &Anchor) -> (f64, f64, f64, f64) { + (sh.col_left(a.cell.col) + a.dx as f64, sh.row_top(a.cell.row) + a.dy as f64, a.width.max(0.0) as f64, a.height.max(0.0) as f64) +} + +/// Decodes an embedded picture to RGBA, downscaled to at most `max_w`×`max_h` pixels. +fn decode_image(data: &[u8], max_w: u32, max_h: u32) -> Option<(Vec, u32, u32)> { + let (w, h) = crate::io::image_size(data)?; + if w == 0 || h == 0 || w as u64 * h as u64 > 60_000_000 { + return None; + } + let img = image::load_from_memory(data).ok()?; + let img = if img.width() > max_w.max(1) || img.height() > max_h.max(1) { img.thumbnail(max_w.max(1), max_h.max(1)) } else { img }; + let rgba = img.to_rgba8(); + let (pw, ph) = (rgba.width(), rgba.height()); + Some((rgba.into_raw(), pw, ph)) +} + +/// Draws charts, pictures, shapes and sparklines overlapping the body block of a page. +/// `pics` memoizes decoded pictures for the whole render (one page at a time draws each +/// picture again, but the decode happens once). +pub(super) fn draw_objects( + pg: &mut Page, + wb: &Workbook, + si: usize, + sh: &Sheet, + b: &Block, + pics: &mut std::collections::HashMap<(usize, usize), Option<(Vec, u32, u32)>>, +) { + 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); + draw_sparklines(pg, wb, si, sh, b, block); + for (imi, im) in sh.images.iter().enumerate() { + 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); + let decoded = pics.entry((si, imi)).or_insert_with(|| decode_image(&im.data, mw, mh)); + if let Some((px, pw, ph)) = decoded.as_ref() { + 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); + draw_shape(pg, wb, shp, b, (x, y, w, h)); + } + 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_sparklines(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block, block: RangeRef) { + for sp in &sh.sparklines { + if !block.contains(sp.cell) { + continue; + } + let vals: Vec> = match gridcraft_calc::evaluate(wb, si, sp.cell, &sp.source) { + Value::Array(a) => a.iter().take(10_000).map(Value::as_f64).collect(), + v => vec![v.as_f64()], + }; + let color = rgb_of(sp.color, wb).unwrap_or([0x15, 0x60, 0x82]); + let (x, y, w, h) = b.rect(RangeRef::cell(sp.cell)); + let (wpx, hpx) = (w / b.k, h / b.k); + let prims = gridcraft_chart::render_sparkline(sp.kind, &vals, [color[0], color[1], color[2], 255], sp.markers, wpx, hpx); + draw_prims(pg, &prims, Xf { ox: x, oy: y, k: b.k }); + } +} + +fn draw_shape(pg: &mut Page, wb: &Workbook, shp: &gridcraft_model::Shape, b: &Block, (x, y, w, h): (f32, f32, f32, f32)) { + 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() { + return; + } + 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; + } +} diff --git a/crates/engine/src/cmd/print/text.rs b/crates/engine/src/cmd/print/text.rs new file mode 100644 index 000000000..2e1029b48 --- /dev/null +++ b/crates/engine/src/cmd/print/text.rs @@ -0,0 +1,263 @@ +use super::draw::{Block, Look, rgb_of}; +use super::*; + +const PAD_PX: f32 = 2.0; + +/// Word-wraps `text` to `width` points. +pub(super) fn wrap_lines(text: &str, font: Font, size: f32, width: f32) -> Vec { + let mut out = Vec::new(); + for para in text.split('\n') { + let mut line = String::new(); + for word in para.split(' ') { + let cand = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; + if line.is_empty() || gridcraft_pdf::text_width(font, size, &cand) <= width { + line = cand; + } else { + out.push(std::mem::take(&mut line)); + line = word.to_string(); + } + // Break a single over-long word by characters. The cut that fits is found by binary + // search (each try measures one candidate prefix), so an over-long word costs + // O(n log n) measurements instead of re-measuring the whole cut per character for + // every emitted line (`a` × 100k pasted text would be cubic worst case). + while gridcraft_pdf::text_width(font, size, &line) > width && line.chars().count() > 1 { + let chars: Vec = line.chars().collect(); + let (mut lo, mut hi) = (1usize, chars.len()); + while lo < hi { + let mid = lo + (hi - lo).div_ceil(2); + let cut: String = chars[..mid].iter().collect(); + if gridcraft_pdf::text_width(font, size, &cut) <= width { + lo = mid; + } else { + hi = mid - 1; + } + } + if lo == chars.len() { + break; + } + out.push(chars[..lo].iter().collect()); + line = chars[lo..].iter().collect(); + } + if out.len() > 2000 { + return out; + } + } + out.push(line); + } + out +} + +fn font_for(style: &Style) -> Font { + Font::styled(Font::family_for(&style.font.name), style.font.bold, style.font.italic) +} + +/// 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)) +} + +/// Unscaled width of the empty Center Across Selection cells right of `c`, which its text is centred over too. +pub(super) fn center_across_width(styles: &StyleTable, sh: &Sheet, c: CellRef, merges: &[RangeRef]) -> f32 { + let mut ext = 0.0; + let mut col = c.col + 1; + while col < MAX_COLS && ext < 20_000.0 { + let nc = CellRef::new(c.row, col); + if !empty_for_overflow(sh, nc, merges) || styles.get(sh.style_id(nc)).align.h != HAlign::CenterAcross { + break; + } + ext += sh.col_width(col); + col += 1; + } + ext +} + +pub(super) 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.and_then(gridcraft_numfmt::FormatColor::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 { + // The look's code, which a conditional format can override (#1163). + crate::display::value_text_as(wb, &v, code) + }; + if !wrap { + text = text.replace(['\n', '\r'], " "); + } + if text.is_empty() { + return; + } + let mut tw = gridcraft_pdf::text_width(font, size, &text); + if st.align.shrink && !wrap && tw > avail && tw > 0.0 { + size = (size * avail / tw).max(1.0); + tw = gridcraft_pdf::text_width(font, size, &text); + } + if is_num && !wrap && tw > avail + 0.01 && !sh.show_formulas { + let hash = gridcraft_pdf::text_width(font, size, "#").max(0.1); + text = "#".repeat(((avail / hash).floor().max(1.0) as usize).min(255)); + tw = gridcraft_pdf::text_width(font, size, &text); + } + let h_align = match st.align.h { + HAlign::General => { + if is_num { + HAlign::Right + } else if matches!(v, Value::Bool(_) | Value::Error(_)) { + HAlign::Center + } else { + HAlign::Left + } + } + HAlign::CenterAcross | HAlign::Distributed => HAlign::Center, + HAlign::Justify | HAlign::Fill => HAlign::Left, + other => other, + }; + let rot = st.align.rotation; + let rotated = rot != 0 && rot != 255; + // Rotated text wraps along its own direction: upright (±90°) text to the row height. + let lines: Vec = match (wrap, rotated) { + (true, false) => wrap_lines(&text, font, size, avail.max(size)), + (true, true) if rot.abs() == 90 => wrap_lines(&text, font, size, (h - 2.0 * pad).max(size)), + (true, true) => vec![text.lines().next().unwrap_or_default().to_string()], + (false, _) => vec![text], + }; + let line_h = size * 1.2; + let n = lines.len().max(1) as f32; + let first_baseline = match st.align.v { + VAlign::Top => y + pad + size * 0.8, + VAlign::Center | VAlign::Justify | VAlign::Distributed => y + (h - n * line_h) / 2.0 + line_h * 0.5 + size * 0.33, + VAlign::Bottom => y + h - pad - size * 0.22 - (n - 1.0) * line_h, + }; + // Overflow into empty neighbours (single-line text, not merged). + // Center Across Selection spans the empty Center Across cells to its right; the text stays within that run. + let spans = st.align.h == HAlign::CenterAcross && !wrap && !merged && rot == 0; + let across = if spans { center_across_width(&wb.styles, sh, c, merges) * b.k } else { 0.0 }; + let (mut clip_l, mut clip_r) = (x, x + w + across); + if !wrap && !merged && !is_num && rot.abs() != 90 && across <= 0.0 && tw + 2.0 * pad + indent > w { + let need = tw + 2.0 * pad + indent - w; + let grow = |dir: i64, mut need: f32| -> f32 { + let mut ext = 0.0; + let mut col = c.col as i64 + dir; + while need > 0.0 && col >= 0 && col < MAX_COLS as i64 { + let nc = CellRef::new(c.row, col as u32); + if !empty_for_overflow(sh, nc, merges) { + break; + } + let cw = sh.col_width(nc.col) * b.k; + ext += cw; + need -= cw; + col += dir; + if ext > 20_000.0 { + break; + } + } + ext + }; + match h_align { + HAlign::Right => clip_l -= grow(-1, need), + HAlign::Center => { + clip_l -= grow(-1, need / 2.0); + clip_r += grow(1, need / 2.0); + } + _ => clip_r += grow(1, need), + } + } + let clip_l = clip_l.max(limits.0); + let clip_r = clip_r.min(limits.1); + pg.save(); + pg.clip_rect(clip_l, y, (clip_r - clip_l).max(0.0), h); + if rotated { + // Rotated text: anchored at the bottom-left (upward) or top-left (downward) of the cell. + // Wrapped lines stack rightward for upward text and leftward for downward text, so + // downward text starts its first line far enough in for the last to end at the left pad. + let deg = (rot as f32).clamp(-90.0, 90.0); + let angle = -deg.to_radians(); + let last = lines.len().saturating_sub(1) as f32; + for (i, t) in lines.iter().enumerate() { + let i = i as f32; + let (ox, oy) = if deg > 0.0 { + (x + pad + size * 0.8 * deg.to_radians().sin() + i * line_h, y + h - pad) + } else { + (x + pad + (last - i) * line_h, y + pad + size * 0.8) + }; + pg.text_rotated(ox, oy, size, font, color, t, angle); + } + pg.restore(); + return; + } + if rot == 255 { + // Vertical stacked text. + let chars: Vec = lines.concat().chars().take(500).collect(); + let mut yy = y + pad + size * 0.8; + for ch in chars { + let s = ch.to_string(); + let cw = gridcraft_pdf::text_width(font, size, &s); + pg.text(x + (w - cw) / 2.0, yy, size, font, color, &s); + yy += line_h; + if yy > y + h + line_h { + break; + } + } + pg.restore(); + return; + } + for (i, line) in lines.iter().enumerate() { + let lw = gridcraft_pdf::text_width(font, size, line); + let lx = match h_align { + HAlign::Right => x + w - pad - lw - indent, + HAlign::Center => x + (w + across - 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(); +}