From 6ea8d11522964cf552867d4848e94bb4bd165676 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 16:08:24 -0500 Subject: [PATCH] print: bound the print area to the grid; render charts once and budget picture and chart output (#3314) * print: hold the print area to the grid; render each chart once and budget picture and chart output across pages * address review: budget chart path points and drop charts that do not fit the budget --- crates/engine/src/cmd/print.rs | 67 ++++++++++++++++++++++++-- crates/engine/src/cmd/print/layout.rs | 22 +++++++++ crates/engine/src/cmd/print/objects.rs | 22 ++++++--- crates/engine/src/cmd/print/tests.rs | 24 +++++++++ 4 files changed, 126 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index b3ce40d58..875ebdc2b 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -32,8 +32,69 @@ 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, }; -/// Decoded pictures (bytes, width, height) keyed by (sheet, picture); `None` = undecodable. -type PicCache = std::collections::HashMap<(usize, usize), Option<(Vec, u32, u32)>>; +type DecodedPicture = (Vec, u32, u32); + +/// What one PDF render shares across the pages an object spans. +#[derive(Default)] +struct PicCache { + /// Decoded pictures (bytes, width, height) keyed by (sheet, picture); `None` = undecodable. + decoded: std::collections::HashMap<(usize, usize), Option>, + /// Laid-out charts (with their [`chart_cost`]) keyed by (sheet, chart), rendered once however + /// many pages they touch. Only charts that can still be drawn are kept. + charts: std::collections::HashMap<(usize, usize), (Vec, usize)>, + /// Charts too big for what was left of the budget: never drawn, so not rendered again. + rejected_charts: std::collections::HashSet<(usize, usize)>, + /// Bytes of picture pixels embedded so far, and chart cost units emitted so far. Each page an + /// object touches gets its own copy in the PDF, so a workbook-controlled anchor over thousands + /// of pages would multiply them without bound. + image_bytes: usize, + chart_prims: usize, +} + +impl PicCache { + /// The chart's primitives for one more page, or `None` once they no longer fit + /// [`MAX_CHART_PRIMS`]. `render` runs at most once per chart; a chart that doesn't fit is + /// dropped at once and remembered, so its vectors are never retained. + fn chart(&mut self, key: (usize, usize), render: impl FnOnce() -> Vec) -> Option<&[gridcraft_chart::Prim]> { + if self.rejected_charts.contains(&key) { + return None; + } + let (prims, cost) = match self.charts.remove(&key) { + Some(cached) => cached, + None => { + let prims = render(); + let cost = chart_cost(&prims); + (prims, cost) + } + }; + if self.chart_prims.saturating_add(cost) > MAX_CHART_PRIMS { + self.rejected_charts.insert(key); + return None; + } + self.chart_prims += cost; + Some(&self.charts.entry(key).or_insert((prims, cost)).0) + } +} + +/// What a chart's drawing costs in the PDF: one unit per primitive plus one per path point (a +/// smoothed line holds thousands of points, each written out) and per point of a wedge's outline. +fn chart_cost(prims: &[gridcraft_chart::Prim]) -> usize { + use gridcraft_chart::Prim; + prims + .iter() + .map(|p| match p { + Prim::Line { pts, .. } | Prim::Polygon { pts, .. } => pts.len().saturating_add(1), + // At most 400 steps along each of the outer and inner arcs (see `draw_prims`). + Prim::Wedge { .. } => 803, + _ => 1, + }) + .fold(0, usize::saturating_add) +} + +/// Most picture pixel bytes (RGBA, before PDF packing) one render embeds across all pages. +const MAX_EMBEDDED_IMAGE_BYTES: usize = 512 * 1024 * 1024; +/// Most chart cost units (primitives and path points, see [`chart_cost`]) one render draws across all pages. +const MAX_CHART_PRIMS: usize = 4_000_000; #[cfg(test)] use layout::bands; @@ -102,7 +163,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str let mut printed_blocks = Vec::new(); // Decoded pictures are reused across the pages their anchor covers: a floating picture // spanning 40 pages was decoded (and pushed into the PDF) 40 times (#2389). - let mut pics = PicCache::new(); + let mut pics = PicCache::default(); for lay in layouts { let Some(sh) = wb.sheet(lay.sheet) else { continue }; let mut cf = if sh.cond_formats.is_empty() { None } else { Some(crate::cf::CfCache::new()) }; diff --git a/crates/engine/src/cmd/print/layout.rs b/crates/engine/src/cmd/print/layout.rs index 0f484f875..0d86de515 100644 --- a/crates/engine/src/cmd/print/layout.rs +++ b/crates/engine/src/cmd/print/layout.rs @@ -224,6 +224,12 @@ pub fn layout_sheet(wb: &Workbook, si: usize, opts: &PrintOptions, range_overrid Some(r) => trim_full(sh, r), None => print_range(sh).unwrap_or(RangeRef::cell(CellRef::new(0, 0))), }; + // A print area from a file or JSON can name coordinates past the grid; pagination walks every + // line of the area (zero-height rows never fill a band), so it is held to the grid first. + let area = RangeRef::new( + CellRef::new(area.start.row.min(MAX_ROWS - 1), area.start.col.min(MAX_COLS - 1)), + CellRef::new(area.end.row.min(MAX_ROWS - 1), area.end.col.min(MAX_COLS - 1)), + ); 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; @@ -379,3 +385,19 @@ pub(super) fn segments(titles: Option<(u32, u32)>, band: (u32, u32)) -> Vec<(u32 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() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_print_area_past_the_grid_is_held_to_the_grid() { + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + sh.default_row_height = 0.0; + sh.print.print_area = Some(RangeRef { start: CellRef::new(0, 0), end: CellRef::new(u32::MAX, u32::MAX) }); + let lay = layout_sheet(&wb, 0, &PrintOptions::default(), None).unwrap(); + assert_eq!((lay.area.end.row, lay.area.end.col), (MAX_ROWS - 1, MAX_COLS - 1)); + assert!(lay.pages.len() <= MAX_PAGES); + } +} diff --git a/crates/engine/src/cmd/print/objects.rs b/crates/engine/src/cmd/print/objects.rs index faa855cb9..d978ff580 100644 --- a/crates/engine/src/cmd/print/objects.rs +++ b/crates/engine/src/cmd/print/objects.rs @@ -88,9 +88,14 @@ pub(super) fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, 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)); + let decoded = pics.decoded.entry((si, imi)).or_insert_with(|| decode_image(&im.data, mw, mh)); if let Some((px, pw, ph)) = decoded.as_ref() { - pg.image_rgba(Rect::new(x, y, w, h), px, [*pw, *ph]); + // Every page the picture touches embeds its own copy: past the budget it is left out. + let bytes = px.len(); + if pics.image_bytes.saturating_add(bytes) <= super::MAX_EMBEDDED_IMAGE_BYTES { + pics.image_bytes += bytes; + pg.image_rgba(Rect::new(x, y, w, h), px, [*pw, *ph]); + } } } for shp in &sh.shapes { @@ -120,15 +125,20 @@ pub(super) fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, } draw_shape(pg, wb, shp, b, (x, y, w, h)); } - for ch in &sh.charts { + for (chi, ch) in sh.charts.iter().enumerate() { 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 }); + // As for pictures, each page gets its own copy of the drawing commands. + let prims = pics.chart((si, chi), || { + let data = gridcraft_chart::resolve(wb, si, ch); + gridcraft_chart::render(ch, &data, r.2 as f32, r.3 as f32, &PdfMeasure) + }); + if let Some(prims) = prims { + draw_prims(pg, prims, Xf { ox: x, oy: y, k: b.k }); + } } pg.restore(); } diff --git a/crates/engine/src/cmd/print/tests.rs b/crates/engine/src/cmd/print/tests.rs index 323060470..5ebd73301 100644 --- a/crates/engine/src/cmd/print/tests.rs +++ b/crates/engine/src/cmd/print/tests.rs @@ -938,3 +938,27 @@ fn equations_print_with_the_symbol_font() { // A glyph no standard font has is a [?] box. assert!(contains(&pdf, "(?) Tj")); } + +#[test] +fn chart_budget_counts_path_points_and_drops_charts_that_do_not_fit() { + use gridcraft_chart::Prim; + let line = |n: usize| Prim::Line { pts: vec![[0.0, 0.0]; n], color: [0, 0, 0, 255], width: 1.0, dash: false }; + // A smoothed line is its points, not one unit. + assert_eq!(chart_cost(&[line(4000), Prim::Circle { cx: 0.0, cy: 0.0, r: 1.0, fill: None, stroke: None }]), 4002); + // Room for exactly two copies of a 1,000-point chart. + let mut pics = PicCache { chart_prims: MAX_CHART_PRIMS - 2002, ..Default::default() }; + let mut renders = 0; + for expect in [true, true, false, false] { + let got = pics.chart((0, 0), || { + renders += 1; + vec![line(1000)] + }); + assert_eq!(got.is_some(), expect); + } + assert_eq!(renders, 1, "a rejected chart is not rendered again"); + assert!(pics.charts.is_empty(), "and its primitives are not kept"); + // A chart too big for what is left is dropped without being cached at all. + let mut pics = PicCache { chart_prims: MAX_CHART_PRIMS - 10, ..Default::default() }; + assert!(pics.chart((0, 1), || vec![line(1000)]).is_none()); + assert!(pics.charts.is_empty() && pics.rejected_charts.contains(&(0, 1))); +}