collective

Browse / github.com/storytold/gridcraft / patch

Security: PDF export keeps a bounded amount of picture data across pages

Security: PDF export keeps a bounded amount of picture data across pages Refs #816. Complements #818. **Problem.** `render_pdf` memoizes picture *decodes* (#2416), but `draw_objects` still calls `Page::image_rgba` for every page a picture overlaps. Each call keeps a fresh RGB (+alpha) copy in `Page::images` until `finish()`. Nothing bounds those copies across the document: `MAX_PAGE_CONTENT` caps only the content string. Anchor extents come from the file (clamped to ±1e7 px), so one picture can cover every page. On a release build of `main` (45f5ab18), a 3 190-byte xlsx with one 4000×4000 PNG anchored at 3000×12000 px: - grew about 29 MiB per page; - passed 2 GiB

Status
open
Score
0.00
Adopters
0 other operators
Author
Higgs32584 (imported from pull request #2720, open)
Base
45f5ab18e4e57bf50ee88d2526deb4b87c5b24cb
Size
+248 −32 in 6 files
Review
pending
Shared
2026-10-28
Id
056e865bbe058b425570c072

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/print.rs crates/engine/src/cmd/print/draw.rs crates/engine/src/cmd/print/objects.rs crates/engine/src/cmd/print/tests.rs crates/pdf/src/lib.rs crates/pdf/src/tests.rs

diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs--- a/crates/engine/src/cmd/print.rs+++ b/crates/engine/src/cmd/print.rs@@ -32,7 +32,7 @@ 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;+use objects::{PicCache, draw_objects}; #[cfg(test)] use text::{center_across_width, wrap_lines}; @@ -84,7 +84,14 @@ pub const MAX_PAGES: usize = 2000;  /// Renders laid-out sheets to a PDF. pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str) -> (Vec<u8>, usize) {+    render_pdf_with_image_budget(wb, layouts, file, path, gridcraft_pdf::MAX_DOC_IMAGE_BYTES)+}++/// [`render_pdf`] keeping at most `image_bytes` of picture data in the document (tests lower it+/// to exercise the cap without building gigabytes of pictures).+fn render_pdf_with_image_budget(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str, image_bytes: u64) -> (Vec<u8>, usize) {     let mut doc = PdfDoc::new();+    doc.set_image_budget(image_bytes);     doc.info.title = if wb.props.title.is_empty() { file.to_string() } else { wb.props.title.clone() };     doc.info.author = wb.props.author.clone();     let total: usize = layouts.iter().map(|l| l.pages.len()).sum();@@ -93,8 +100,9 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str     let mut pending_links = Vec::new();     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: std::collections::HashMap<(usize, usize), Option<(Vec<u8>, u32, u32)>> = std::collections::HashMap::new();+    // spanning 40 pages was decoded 40 times (#2389). Each page still keeps its own copy in the+    // PDF, within the document's image budget.+    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()) };@@ -117,7 +125,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str                 let mut x = ox + lay.heading_w * k;                 for (csi, cs) in csegs.iter().enumerate() {                     let b = make_block(wb, lay.sheet, sh, *rs, *cs, x, y, lay);-                    draw_block(pg, &b, lay.settings.gridlines, &mut cf);+                    draw_block(pg, &b, lay.settings.gridlines, &mut cf, &mut pics);                     draw_block_links(pg, &b, page_index, &mut pending_links);                     printed_blocks.push(PrintedBlock { page: page_index, sheet: lay.sheet, rows: *rs, cols: *cs, x, y, k });                     if lay.settings.headings {diff --git a/crates/engine/src/cmd/print/draw.rs b/crates/engine/src/cmd/print/draw.rs--- a/crates/engine/src/cmd/print/draw.rs+++ b/crates/engine/src/cmd/print/draw.rs@@ -1,5 +1,5 @@ use super::layout::{DENSE_BLOCK, SheetLayout};-use super::objects::draw_cell_picture;+use super::objects::{PicCache, draw_cell_picture}; use super::text::draw_text; use super::*; @@ -225,7 +225,7 @@ fn draw_border(pg: &mut Page, b: &BorderLine, wb: &Workbook, x0: f32, y0: f32, x }  /// 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<crate::cf::CfCache>) {+pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Option<crate::cf::CfCache>, pics: &mut PicCache) {     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 {@@ -338,7 +338,7 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt             continue;         }         if let Some(picture) = sh.cell_pictures.get(c) {-            draw_cell_picture(pg, &picture.data, (x, y, w, h), b.k);+            draw_cell_picture(pg, pics, (b.si, *c), &picture.data, (x, y, w, h), b.k);         } else {             draw_text(pg, b, *c, l, (x, y, w, h), merged, &merges, (bx, bx + bw));         }diff --git a/crates/engine/src/cmd/print/objects.rs b/crates/engine/src/cmd/print/objects.rs--- a/crates/engine/src/cmd/print/objects.rs+++ b/crates/engine/src/cmd/print/objects.rs@@ -1,9 +1,63 @@ use super::draw::{Block, PdfMeasure, Xf, draw_prims, rgb_of}; use super::text::wrap_lines; use super::*;+use std::borrow::Cow;+use std::collections::hash_map::Entry;++/// A decoded picture: RGBA pixels, width, height.+type Decoded = (Vec<u8>, u32, u32);++/// Most bytes of decoded pictures one render memoizes; a picture decoded past it is used once+/// and dropped (and decoded again on the next page that prints it).+const MAX_PIC_CACHE_BYTES: u64 = 256 * 1024 * 1024;++#[derive(Clone, Copy, PartialEq, Eq, Hash)]+enum PicKey {+    /// A floating picture: sheet index and position in `Sheet::images`.+    Floating(usize, usize),+    /// A cell picture: sheet index, cell and the thumbnail size it is decoded at.+    Cell(usize, CellRef, u32, u32),+}++/// Decoded pictures reused across the pages (and repeated title rows) that print them, so a+/// picture is decoded once per render instead of once per page (#2389).+#[derive(Default)]+pub(super) struct PicCache {+    map: std::collections::HashMap<PicKey, Option<Decoded>>,+    /// Bytes of pixels in `map`.+    bytes: u64,+}++impl PicCache {+    /// The decoded picture for `key`, running `decode` the first time. A failed decode is+    /// remembered too; a decode that does not fit in the cache is returned without being kept.+    fn get(&mut self, key: PicKey, decode: impl FnOnce() -> Option<Decoded>) -> Option<Cow<'_, Decoded>> {+        let slot = match self.map.entry(key) {+            Entry::Occupied(e) => e.into_mut(),+            Entry::Vacant(e) => {+                let decoded = decode();+                let size = decoded.as_ref().map_or(0, |d| d.0.len() as u64);+                if self.bytes.saturating_add(size) > MAX_PIC_CACHE_BYTES {+                    return decoded.map(Cow::Owned);+                }+                self.bytes = self.bytes.saturating_add(size);+                e.insert(decoded)+            }+        };+        slot.as_ref().map(Cow::Borrowed)+    }+}++/// Whether a picture decoded into at most `max_w`×`max_h` pixels can still be kept by the PDF.+/// The document's image room only shrinks while it is built, so a picture refused here would be+/// refused on every later page too and is not decoded at all.+fn fits(room: u64, max_w: u32, max_h: u32) -> bool {+    u64::from(max_w).saturating_mul(u64::from(max_h)).saturating_mul(4) <= room+}  /// 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) {+/// `at` (sheet index and cell) keys the decode in `pics`.+pub(super) fn draw_cell_picture(pg: &mut Page, pics: &mut PicCache, at: (usize, CellRef), 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));@@ -14,18 +68,30 @@ pub(super) fn draw_cell_picture(pg: &mut Page, data: &[u8], rect: (f32, f32, f32     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 (tw, th) = (((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 room = pg.image_room();+    let Some(image) = pics.get(PicKey::Cell(at.0, at.1, tw, th), || decode_cell_picture(data, tw, th, room)) else { return };+    let (px, px_w, px_h) = &*image;+    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, px, *px_w, *px_h);+}++/// Decodes a cell picture to RGBA scaled to fit `tw`×`th` pixels, unless the PDF has no `room`+/// left for it.+fn decode_cell_picture(data: &[u8], tw: u32, th: u32, room: u64) -> Option<Decoded> {+    if !fits(room, tw, th) {+        return None;+    }+    let mut reader = image::ImageReader::new(std::io::Cursor::new(data)).with_guessed_format().ok()?;     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());+    let image = reader.decode().ok()?.thumbnail(tw, th).to_rgba8();+    let (pw, ph) = (image.width(), image.height());+    Some((image.into_raw(), pw, ph)) }  /// Sheet pixel rectangle of an anchor.@@ -33,10 +99,11 @@ 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<u8>, u32, u32)> {+/// Decodes an embedded picture to RGBA, downscaled to at most `max_w`×`max_h` pixels, unless the+/// PDF has no `room` left for it.+fn decode_image(data: &[u8], max_w: u32, max_h: u32, room: u64) -> Option<Decoded> {     let (w, h) = crate::io::image_size(data)?;-    if w == 0 || h == 0 || w as u64 * h as u64 > 60_000_000 {+    if w == 0 || h == 0 || w as u64 * h as u64 > 60_000_000 || !fits(room, w.min(max_w.max(1)), h.min(max_h.max(1))) {         return None;     }     let img = image::load_from_memory(data).ok()?;@@ -49,14 +116,7 @@ fn decode_image(data: &[u8], max_w: u32, max_h: u32) -> Option<(Vec<u8>, u32, u3 /// 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<u8>, u32, u32)>>,-) {+pub(super) fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block, pics: &mut PicCache) {     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)));@@ -75,8 +135,9 @@ pub(super) fn draw_objects(         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() {+        let room = pg.image_room();+        if let Some(decoded) = pics.get(PicKey::Floating(si, imi), || decode_image(&im.data, mw, mh, room)) {+            let (px, pw, ph) = &*decoded;             pg.image_rgba(x, y, w, h, px, *pw, *ph);         }     }diff --git a/crates/engine/src/cmd/print/tests.rs b/crates/engine/src/cmd/print/tests.rs--- a/crates/engine/src/cmd/print/tests.rs+++ b/crates/engine/src/cmd/print/tests.rs@@ -359,6 +359,62 @@ fn charts_and_images_are_drawn() {     } } +/// Bytes of pixel data in a PDF's image XObjects: 3 per RGB pixel, 1 per soft-mask pixel.+fn image_bytes(pdf: &[u8]) -> u64 {+    let text = String::from_utf8_lossy(pdf);+    text.split("/Subtype /Image")+        .skip(1)+        .map(|obj| {+            let dict = &obj[..obj.find(">>").unwrap()];+            let num = |key: &str| -> u64 {+                let at = dict.find(key).unwrap() + key.len();+                dict[at..].split_whitespace().next().unwrap().parse().unwrap()+            };+            num("/Width ") * num("/Height ") * if dict.contains("/DeviceRGB") { 3 } else { 1 }+        })+        .sum()+}++#[test]+fn picture_copies_across_pages_stay_within_the_image_budget() {+    // Each page a picture overlaps kept its own decoded copy in the PDF with no document-wide+    // bound: a 3 KB workbook with one tall picture reached 2 GiB before running out of pages.+    let mut s = s();+    let mut png = Vec::new();+    let img = image::RgbaImage::from_pixel(64, 64, image::Rgba([10, 120, 200, 255]));+    image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();+    s.execute("cell.set", json!({"cell": "A1", "input": "Title"})).unwrap();+    s.execute("pageLayout.pageSetup", json!({"titleRows": "1:1"})).unwrap();+    let d = s.doc_mut().unwrap();+    let wb = std::sync::Arc::make_mut(&mut d.wb);+    let id = wb.next_object_id();+    let sh = wb.sheet_mut(0).unwrap();+    // A floating picture over some 20 pages, and a cell picture in the title row on every page.+    sh.images.push(Image {+        id,+        anchor: Anchor { cell: CellRef::new(1, 1), dx: 0.0, dy: 0.0, width: 200.0, height: 19_990.0, mode: gridcraft_model::AnchorMode::MoveAndSize },+        data: std::sync::Arc::new(png.clone()),+        mime: "image/png".into(),+        alt: String::new(),+    });+    sh.cell_pictures.insert(CellRef::new(0, 2), std::sync::Arc::new(CellPicture { data: png, mime: "image/png".into(), alt: String::new() }));+    let d = s.doc().unwrap();+    let lays = layouts(&d.wb, &PrintOptions::from_params(&json!({}), "file.exportPdf").unwrap());++    let (full, pages) = render_pdf(&d.wb, &lays, "t", "");+    assert!(pages >= 15, "{pages} pages");+    // Within the default budget every page keeps its own copy (64×64 RGB is 12,288 bytes).+    let full_bytes = image_bytes(&full);+    assert!(full_bytes >= (pages as u64 - 1) * 12_288, "{full_bytes} bytes of images on {pages} pages");++    let budget = 64 * 1024;+    let (capped, capped_pages) = render_pdf_with_image_budget(&d.wb, &lays, "t", "", budget);+    assert_eq!(capped_pages, pages, "pages past the budget still print, without the pictures");+    let kept = image_bytes(&capped);+    assert!((12_288..=budget).contains(&kept), "{kept} bytes of images kept with a {budget}-byte budget");+    assert!(contains(&capped, "%%EOF"));+}+ #[test] fn page_setup_edits_settings() {     let mut s = s();diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs--- a/crates/pdf/src/lib.rs+++ b/crates/pdf/src/lib.rs@@ -5,7 +5,9 @@ //!   WinAnsiEncoding (CJK and other scripts) are set in a predefined, non-embedded Adobe-GB1 CID //!   font with the `UniGB-UTF16-H` CMap instead of being replaced by `?`. //! - Vector drawing: rectangles, lines (solid or dashed), polygons, circles (Bézier), clipping.-//! - RGBA images as RGB image XObjects with a soft mask for alpha (run-length encoded).+//! - RGBA images as RGB image XObjects with a soft mask for alpha (run-length encoded). The+//!   decoded image bytes one document keeps across all its pages are capped+//!   ([`MAX_DOC_IMAGE_BYTES`]); images past the cap are left out. //! - Coordinates are points with a **top-left origin** (y grows down); text `y` is the baseline. //! //! Streams are written uncompressed (images use the trivial RunLengthDecode filter) so the crate@@ -321,6 +323,9 @@ pub struct Page {     content: String,     fonts: [bool; FONT_SLOTS],     images: Vec<ImageData>,+    /// Image bytes this page may still keep before the document's image budget is spent. Set by+    /// [`PdfDoc::add_page`] and [`PdfDoc::page_mut`], which hand out one page at a time.+    image_room: u64,     links: Vec<LinkAnnotation>,     depth: u32, }@@ -330,6 +335,10 @@ pub struct Page { const MAX_PAGE_CONTENT: usize = 64 * 1024 * 1024; /// Largest image (pixels) embedded. const MAX_IMAGE_PIXELS: u64 = 40_000_000;+/// Most bytes of image data (RGB plus alpha planes) one document keeps across all its pages.+/// Each [`Page::image_rgba`] call keeps its own copy, so a picture repeated on every page of a+/// long export would otherwise grow without bound; images past the budget are left out.+pub const MAX_DOC_IMAGE_BYTES: u64 = 512 * 1024 * 1024; const MAX_PAGE_LINKS: usize = 4096; const MAX_LINK_URI_BYTES: usize = 4096; @@ -346,6 +355,20 @@ impl Page {     fn full(&self) -> bool {         self.content.len() > MAX_PAGE_CONTENT     }++    /// Bytes of image data this page may still add before the document's image budget+    /// ([`MAX_DOC_IMAGE_BYTES`]) is spent. An image of `pw`×`ph` pixels needs up to `pw * ph * 4`;+    /// callers can skip decoding pictures that cannot fit (the room never grows back while a+    /// document is built).+    pub fn image_room(&self) -> u64 {+        self.image_room+    }++    /// Bytes of image data kept by this page.+    fn image_bytes(&self) -> u64 {+        self.images.iter().fold(0u64, |acc, i| acc.saturating_add(i.rgb.len() as u64).saturating_add(i.alpha.as_ref().map_or(0, |a| a.len() as u64)))+    }+     fn op(&mut self, s: &str) {         if !self.full() {             self.content.push_str(s);@@ -576,12 +599,18 @@ impl Page {     }      /// Draws an RGBA image (`pw`×`ph` pixels, row-major, straight alpha) into the box-    /// `(x, y, w, h)`. Malformed or oversized buffers are ignored.+    /// `(x, y, w, h)`. Malformed or oversized buffers are ignored, and so are images that do not+    /// fit in what is left of the document's image budget ([`Page::image_room`]).     pub fn image_rgba(&mut self, x: f32, y: f32, w: f32, h: f32, rgba: &[u8], pw: u32, ph: u32) {         let px = pw as u64 * ph as u64;         if pw == 0 || ph == 0 || px > MAX_IMAGE_PIXELS || (rgba.len() as u64) < px * 4 || !(extent(w) && extent(h) && finite(&[x, y])) {             return;         }+        // Room for the worst case (RGB plus an alpha plane) is checked before anything is+        // allocated, so the document's image bytes stay bounded however many pages it has.+        if px * 4 > self.image_room {+            return;+        }         let px = px as usize;         let mut rgb = Vec::with_capacity(px * 3);         let mut alpha = Vec::with_capacity(px);@@ -590,6 +619,8 @@ impl Page {             alpha.push(p[3]);         }         let alpha = if alpha.iter().all(|a| *a == 255) { None } else { Some(alpha) };+        let kept = (rgb.len() as u64).saturating_add(alpha.as_ref().map_or(0, |a| a.len() as u64));+        self.image_room = self.image_room.saturating_sub(kept);         let idx = self.images.len();         self.images.push(ImageData { width: pw, height: ph, rgb, alpha });         let s = format!("q {} 0 0 {} {} {} cm /Im{} Do Q", n(w), n(h), n(x), n(self.y(y + h)), idx + 1);@@ -610,6 +641,8 @@ pub struct Info { pub struct PdfDoc {     pages: Vec<Page>,     pub info: Info,+    /// Image budget lowered by [`PdfDoc::set_image_budget`]; `None` = [`MAX_DOC_IMAGE_BYTES`].+    image_budget: Option<u64>, }  /// PackBits-style run-length encoding (PDF `RunLengthDecode`).@@ -677,18 +710,43 @@ impl PdfDoc {     pub fn add_page(&mut self, width_pt: f32, height_pt: f32) -> &mut Page {         let width = if width_pt.is_finite() { width_pt.clamp(3.0, 14_400.0) } else { 612.0 };         let height = if height_pt.is_finite() { height_pt.clamp(3.0, 14_400.0) } else { 792.0 };-        self.pages.push(Page { width, height, content: String::new(), fonts: [false; FONT_SLOTS], images: Vec::new(), links: Vec::new(), depth: 0 });+        let image_room = self.image_room();+        self.pages.push(Page {+            width,+            height,+            content: String::new(),+            fonts: [false; FONT_SLOTS],+            images: Vec::new(),+            image_room,+            links: Vec::new(),+            depth: 0,+        });         let i = self.pages.len() - 1;         &mut self.pages[i]     } +    /// Lowers the bytes of image data the whole document keeps (never above+    /// [`MAX_DOC_IMAGE_BYTES`]); images drawn past it are left out.+    pub fn set_image_budget(&mut self, bytes: u64) {+        self.image_budget = Some(bytes.min(MAX_DOC_IMAGE_BYTES));+    }++    /// Image bytes the document may still keep: its budget minus what every page holds.+    fn image_room(&self) -> u64 {+        let used = self.pages.iter().fold(0u64, |acc, p| acc.saturating_add(p.image_bytes()));+        self.image_budget.unwrap_or(MAX_DOC_IMAGE_BYTES).saturating_sub(used)+    }+     pub fn page_count(&self) -> usize {         self.pages.len()     }      /// The page at `i`, to keep drawing on it.     pub fn page_mut(&mut self, i: usize) -> Option<&mut Page> {-        self.pages.get_mut(i)+        let room = self.image_room();+        let page = self.pages.get_mut(i)?;+        page.image_room = room;+        Some(page)     }      /// Serializes the document. A document without pages gets one blank Letter page.diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs--- a/crates/pdf/src/tests.rs+++ b/crates/pdf/src/tests.rs@@ -318,3 +318,36 @@ fn cjk_text_is_not_replaced_by_question_marks() {     // Measured as full-width glyphs, matching the CID font's default width.     assert_eq!(text_width(Font::Helvetica, 10.0, "签字"), 20.0); }++#[test]+fn image_bytes_are_bounded_across_pages() {+    // Each page keeps its own copy of a picture it draws, so a picture repeated on every page of+    // a long export grew without bound (a 3 KB workbook reached 2 GiB). The document-wide budget+    // stops adding copies once it is spent.+    let mut d = PdfDoc::new();+    assert_eq!(d.add_page(100.0, 100.0).image_room(), MAX_DOC_IMAGE_BYTES);+    d.set_image_budget(u64::MAX);+    assert_eq!(d.add_page(100.0, 100.0).image_room(), MAX_DOC_IMAGE_BYTES, "the budget is never raised");++    let mut d = PdfDoc::new();+    d.set_image_budget(10_000);+    // Opaque 32×32: 4096 bytes of room needed, 3072 bytes (RGB, no mask) kept.+    let px = [200u8, 200, 200, 255].repeat(32 * 32);+    for _ in 0..50 {+        let p = d.add_page(100.0, 100.0);+        p.image_rgba(0.0, 0.0, 50.0, 50.0, &px, 32, 32);+    }+    let kept: u64 = d.pages.iter().map(Page::image_bytes).sum();+    assert_eq!(kept, 2 * 3072);+    assert_eq!(d.pages.iter().filter(|p| !p.images.is_empty()).count(), 2);+    assert!(d.pages.iter().skip(2).all(|p| p.images.is_empty() && p.content.is_empty()));+    // A page reopened later sees what the whole document already holds.+    let p = d.page_mut(0).unwrap();+    assert_eq!(p.image_room(), 10_000 - 2 * 3072);+    p.image_rgba(0.0, 0.0, 50.0, 50.0, &px, 32, 32);+    p.image_rgba(0.0, 0.0, 10.0, 10.0, &[255; 4 * 4 * 4], 4, 4);+    assert_eq!(d.pages[0].images.len(), 2, "the big copy is refused, the small one still fits");+    let pdf = d.finish();+    check_xref(&pdf);+    assert_eq!(String::from_utf8_lossy(&pdf).matches("/Subtype /Image").count(), 3);+}