Compare commits

..
8 changed files with 63 additions and 103 deletions
+21 -3
View File
@@ -32,8 +32,26 @@ 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<u8>, u32, u32)>>;
type DecodedPicture = (Vec<u8>, 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<DecodedPicture>>,
/// Laid-out charts keyed by (sheet, chart), rendered once however many pages they touch.
charts: std::collections::HashMap<(usize, usize), Vec<gridcraft_chart::Prim>>,
/// Bytes of picture pixels embedded so far, and chart primitives 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,
}
/// 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 primitives one render draws across all pages.
const MAX_CHART_PRIMS: usize = 4_000_000;
#[cfg(test)]
use layout::bands;
@@ -102,7 +120,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()) };
+22
View File
@@ -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);
}
}
+17 -6
View File
@@ -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,21 @@ 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 });
let prims = pics.charts.entry((si, chi)).or_insert_with(|| {
let data = gridcraft_chart::resolve(wb, si, ch);
gridcraft_chart::render(ch, &data, r.2 as f32, r.3 as f32, &PdfMeasure)
});
// As for pictures, each page gets its own copy of the drawing commands.
if pics.chart_prims.saturating_add(prims.len()) <= super::MAX_CHART_PRIMS {
pics.chart_prims += prims.len();
draw_prims(pg, prims, Xf { ox: x, oy: y, k: b.k });
}
}
pg.restore();
}
+2 -21
View File
@@ -52,19 +52,14 @@ pub(super) fn abs_ref(c: CellRef) -> String {
format!("${}${}", gridcraft_core::col_to_letters(c.col), u64::from(c.row) + 1)
}
/// How many cells to the left and above a cell its name is searched for.
const NAME_REACH: u32 = 1_000;
/// A cell's name in the reports: the nearest text to its left and the nearest text above it.
pub(super) fn cell_name(sh: &Sheet, c: CellRef) -> String {
let text = |c: CellRef| match sh.value(c) {
Value::Text(t) if !t.trim().is_empty() => Some(t.to_string()),
_ => None,
};
// Labels are next to their cells: looking further than this on a sparse sheet only costs time
// (a thousand cells near the last row would each probe a million empty coordinates).
let left = (c.col.saturating_sub(NAME_REACH)..c.col).rev().find_map(|col| text(CellRef::new(c.row, col)));
let top = (c.row.saturating_sub(NAME_REACH)..c.row).rev().find_map(|row| text(CellRef::new(row, c.col)));
let left = (0..c.col).rev().find_map(|col| text(CellRef::new(c.row, col)));
let top = (0..c.row).rev().find_map(|row| text(CellRef::new(row, c.col)));
[left, top].into_iter().flatten().collect::<Vec<_>>().join(" ")
}
@@ -443,17 +438,3 @@ pub(super) fn report(s: &mut Session, p: &Json) -> Result<Json> {
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_name_is_searched_only_a_bounded_distance() {
let mut sh = Sheet::new("S".to_string());
sh.set_cell(CellRef::new(0, 0), gridcraft_model::Cell::value(Value::text("Far")));
sh.set_cell(CellRef::new(5, 4), gridcraft_model::Cell::value(Value::text("Near")));
assert_eq!(cell_name(&sh, CellRef::new(5, 5)), "Near");
assert_eq!(cell_name(&sh, CellRef::new(0, NAME_REACH + 1)), "");
}
}
+1 -11
View File
@@ -189,9 +189,6 @@ pub(super) fn scenario_delete(s: &mut Session, p: &Json) -> Result<Json> {
Ok(json!({"deleted": removed.name}))
}
/// Most changing-cell-by-scenario cells one Scenario Summary writes.
const MAX_SUMMARY_CELLS: usize = 1_000_000;
pub(super) fn scenario_summary(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "data.scenarioSummary";
let d = s.doc()?;
@@ -214,24 +211,17 @@ pub(super) fn scenario_summary(s: &mut Session, p: &Json) -> Result<Json> {
// Changing cells of every scenario, first-seen order; per scenario its values.
let mut changing: Vec<CellRef> = Vec::new();
let mut per: Vec<HashMap<CellRef, Value>> = Vec::new();
let mut seen: std::collections::HashSet<CellRef> = Default::default();
for sc in &scenarios {
let (_, cells) = cell_list(&d.wb, &sc.changing, CMD, MAX_SCENARIO_CELLS)?;
let mut m = HashMap::new();
for (c, v) in cells.iter().zip(sc.values.iter()) {
if seen.insert(*c) {
if !changing.contains(c) {
changing.push(*c);
}
m.insert(*c, json_value(v, &d.wb));
}
per.push(m);
}
// The summary has a cell for every changing cell in every scenario, filled from the current
// value where a scenario doesn't set it, so small disjoint scenarios multiply.
// Each result cell is a row of the summary too, with a cell per scenario.
if changing.len().saturating_add(results.len()).saturating_mul(per.len()) > MAX_SUMMARY_CELLS {
return Err(bad(CMD, "the scenarios together change too many cells for a summary"));
}
let current: Vec<Value> = changing.iter().map(|c| sh.value(*c)).collect();
let current_results: Vec<Value> = results.iter().map(|c| sh.value(*c)).collect();
let mut trial = Trial::new(d);
-22
View File
@@ -297,25 +297,3 @@ fn consolidate_by_position_and_label() {
assert!(s.execute("data.consolidate", json!({"sources": []})).is_err());
assert!(s.execute("data.consolidate", json!({"sources": ["A1"], "function": "median"})).is_err());
}
#[test]
fn scenario_summary_refuses_a_matrix_over_its_cell_limit() {
let mut s = s();
// 200 scenarios of 32 disjoint cells: 6,400 changing cells x 200 scenarios = 1.28 million cells.
for i in 0..200 {
let (a, b) = (i * 32 + 1, i * 32 + 32);
s.execute("data.scenarioAdd", json!({"name": format!("S{i}"), "changing": format!("A{a}:A{b}")})).unwrap();
}
assert!(s.execute("data.scenarioSummary", json!({})).is_err());
}
#[test]
fn scenario_summary_counts_result_rows_in_its_cell_limit() {
let mut s = s();
// 900 scenarios of one distinct cell each are 810,000 cells; 255 result rows take it past the limit.
for i in 0..900 {
s.execute("data.scenarioAdd", json!({"name": format!("S{i}"), "changing": format!("A{}", i + 1)})).unwrap();
}
let err = s.execute("data.scenarioSummary", json!({"resultCells": "C1:C255"})).unwrap_err().to_string();
assert!(err.contains("too many cells"), "{err}");
}
-17
View File
@@ -237,22 +237,6 @@ const JOURNAL_MAX_ENTRIES: usize = 10_000;
pub(crate) const JOURNAL_MAX_BYTES: usize = 64 * 1024 * 1024;
const UNDO_BYTES_LIMIT: usize = 128 * 1024 * 1024;
/// Comment and hyperlink text of a sheet: a changed sheet's snapshot keeps its own copy of these
/// strings (an edit deep-copies them), so they count against the history budget with the cells.
fn sheet_text_bytes(sheet: &gridcraft_model::Sheet) -> usize {
let comments = sheet.comments.values().fold(0usize, |sum, c| {
// The vectors' own storage counts too: an empty reply still takes an element.
let replies = c.replies.iter().fold(0usize, |n, (a, t)| n.saturating_add(a.len()).saturating_add(t.len()));
let replies = replies.saturating_add(c.replies.capacity().saturating_mul(std::mem::size_of::<(String, String)>()));
let dates = c.dates.iter().fold(0usize, |n, d| n.saturating_add(d.len()));
let dates = dates.saturating_add(c.dates.capacity().saturating_mul(std::mem::size_of::<String>()));
sum.saturating_add(std::mem::size_of::<gridcraft_model::Comment>() + c.author.len() + c.text.len() + replies + dates)
});
sheet.hyperlinks.values().fold(comments, |sum, h| {
sum.saturating_add(std::mem::size_of::<gridcraft_model::Hyperlink>() + h.target.len() + h.tooltip.as_ref().map_or(0, String::len))
})
}
fn history_retained_bytes(older: &Workbook, newer: &Workbook) -> usize {
let mut bytes = std::mem::size_of::<Workbook>();
for (index, sheet) in older.sheets.iter().enumerate() {
@@ -264,7 +248,6 @@ fn history_retained_bytes(older: &Workbook, newer: &Workbook) -> usize {
let empty = gridcraft_model::CellStore::new();
let next = newer.sheets.get(index).map_or(&empty, |next| &next.cells);
bytes = bytes.saturating_add(sheet.cells.retained_bytes_against(next));
bytes = bytes.saturating_add(sheet_text_bytes(sheet));
}
// Snapshots share the macro list until a change to it; a snapshot with its own list keeps it.
if !older.macros.shares(&newer.macros) {
-23
View File
@@ -5572,26 +5572,3 @@ fn clearing_links_and_notes_keeps_allow_edit_ranges() {
let sh = s.doc().unwrap().wb.active().unwrap().clone();
assert!(sh.hyperlinks.is_empty() && sh.comments.is_empty());
}
#[test]
fn history_charges_a_changed_sheets_comment_text() {
let mut s = s();
s.execute("review.newNote", json!({"cell": "A1", "text": "n".repeat(30_000), "author": "Example"})).unwrap();
let older = (*s.doc().unwrap().wb).clone();
s.execute("cell.set", json!({"cell": "B2", "input": "x"})).unwrap();
let newer = (*s.doc().unwrap().wb).clone();
// The older snapshot owns the comment text now that the sheet changed under it.
assert!(crate::history_retained_bytes(&older, &newer) >= 30_000);
}
#[test]
fn history_charges_the_storage_of_empty_comment_replies() {
let mut s = s();
s.execute("review.newComment", json!({"cell": "A1", "text": "c", "author": "Ann"})).unwrap();
for _ in 0..500 {
s.execute("review.replyComment", json!({"cell": "A1", "text": "", "author": ""})).unwrap();
}
let sheet = s.doc().unwrap().wb.sheet(0).unwrap().clone();
// 500 replies of no text are still 500 elements.
assert!(crate::sheet_text_bytes(&sheet) >= 500 * std::mem::size_of::<(String, String)>());
}