mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-08 11:29:46 -06:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
820f1f7533 |
@@ -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()) };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
+4
-36
@@ -907,12 +907,6 @@ const MAX_READ_BYTES: u64 = 2 * 1024 * 1024 * 1024;
|
||||
pub fn read_file(path: &str) -> Result<Vec<u8>> {
|
||||
use std::io::Read;
|
||||
script_sandbox_check(path)?;
|
||||
// Checked before opening: opening a FIFO nobody writes to blocks the calling (GUI) thread.
|
||||
// shortcut: a FIFO swapped in between this check and the open still blocks; close that with
|
||||
// O_NONBLOCK when the crate takes a libc dependency.
|
||||
if !std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?.is_file() {
|
||||
return Err(EngineError::Other(format!("{path}: only regular files can be opened")));
|
||||
}
|
||||
let f = std::fs::File::open(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
// A symlink to an endless device reports length 0 but streams forever, so metadata
|
||||
// alone never bounds the read: require a real file and cap the bytes actually taken.
|
||||
@@ -944,37 +938,18 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
// link's target; and it inherits the destination's access restrictions, so saving a
|
||||
// private workbook doesn't widen who can read it.
|
||||
let tmp = format!("{path}.sctmp");
|
||||
let prior_meta = std::fs::metadata(path).ok();
|
||||
let prior = prior_meta.as_ref().map(std::fs::Metadata::permissions);
|
||||
let prior = std::fs::metadata(path).ok().map(|m| m.permissions());
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create_new(true);
|
||||
// Over an existing file the replacement starts owner-only, whatever the umask: its group is
|
||||
// still the saving user's primary one until it is changed below, and a member of that group
|
||||
// must not be able to open it (and keep the descriptor) before then.
|
||||
#[cfg(unix)]
|
||||
if prior.is_some() {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
opts.mode(0o600);
|
||||
if let Some(p) = &prior {
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
opts.mode(p.mode());
|
||||
}
|
||||
let mut f = opts.open(&tmp).map_err(|e| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
EngineError::Other(format!("{path}: {e}"))
|
||||
})?;
|
||||
// The replacement belongs to the saving user's primary group: restore the old file's group and
|
||||
// then its mode. When the group can't be restored its access is dropped rather than handed to a
|
||||
// different group; failing to read that back is a failed save.
|
||||
#[cfg(unix)]
|
||||
if let Some(m) = &prior_meta {
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||||
let _ = std::os::unix::fs::fchown(&f, None, Some(m.gid()));
|
||||
let group_kept = f.metadata().map(|now| now.gid() == m.gid()).map_err(|e| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
EngineError::Other(format!("{path}: {e}"))
|
||||
})?;
|
||||
// Best effort: a file system that can't change modes keeps the owner-only one. Without the
|
||||
// group, the old owner and other bits are kept and the group, setuid and setgid bits dropped.
|
||||
let _ = f.set_permissions(std::fs::Permissions::from_mode(restored_mode(m.mode(), group_kept)));
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
if let Some(p) = &prior {
|
||||
let _ = f.set_permissions(p.clone());
|
||||
@@ -991,13 +966,6 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The mode a saved file gets from the file it replaces (`old`): all of it when its group was
|
||||
/// restored; without the group, the owner and other bits only.
|
||||
#[cfg(unix)]
|
||||
fn restored_mode(old: u32, group_kept: bool) -> u32 {
|
||||
if group_kept { old & 0o7777 } else { old & 0o707 }
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn write_file(path: &str, _bytes: &[u8]) -> Result<()> {
|
||||
script_sandbox_check(path)?;
|
||||
|
||||
@@ -51,14 +51,6 @@ fn save_keeps_the_destination_access_restrictions() {
|
||||
write_file(dest.to_str().unwrap(), b"v2").unwrap();
|
||||
let mode = std::fs::metadata(&dest).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600);
|
||||
// The mode is set outright from the old file's, not through the umask: group and other access
|
||||
// it had stay as they were.
|
||||
for old in [0o640, 0o664] {
|
||||
std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(old)).unwrap();
|
||||
write_file(dest.to_str().unwrap(), b"v3").unwrap();
|
||||
assert_eq!(std::fs::metadata(&dest).unwrap().permissions().mode() & 0o777, old);
|
||||
}
|
||||
assert!(!dir.join("private.xlsx.sctmp").exists());
|
||||
// And a brand-new file is created with the restrictive, not permissive, default.
|
||||
let fresh = dir.join("new.xlsx");
|
||||
write_file(fresh.to_str().unwrap(), b"v").unwrap();
|
||||
@@ -67,17 +59,6 @@ fn save_keeps_the_destination_access_restrictions() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Without the old group, a saved file keeps the old owner and other bits and nothing for a group.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn restored_mode_drops_the_group_and_special_bits_when_the_group_is_lost() {
|
||||
assert_eq!(super::restored_mode(0o100_664, true), 0o664);
|
||||
assert_eq!(super::restored_mode(0o100_664, false), 0o604);
|
||||
assert_eq!(super::restored_mode(0o102_444, false), 0o404, "an original read-only file stays read-only, setgid is dropped");
|
||||
assert_eq!(super::restored_mode(0o104_755, false), 0o705, "setuid is dropped");
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn open_refuses_directories_and_unknowable_lengths() {
|
||||
@@ -780,20 +761,3 @@ fn sylk_survives_huge_relative_offsets_and_width_floods() {
|
||||
let (wb, _) = open_bytes("widths.slk", &flood).unwrap();
|
||||
assert_eq!(wb.sheet(0).unwrap().value(CellRef::parse("A1").unwrap()), gridcraft_core::Value::Number(1.0));
|
||||
}
|
||||
|
||||
/// A FIFO with no writer would block an open forever: it is refused before it is opened.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn reading_a_fifo_is_refused_without_blocking() {
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-fifo-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let fifo = dir.join("pipe.xlsx");
|
||||
if !std::process::Command::new("mkfifo").arg(&fifo).status().is_ok_and(|s| s.success()) {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
return; // no mkfifo here
|
||||
}
|
||||
let err = read_file(fifo.to_str().unwrap()).unwrap_err().to_string();
|
||||
assert!(err.contains("only regular files"), "{err}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user