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);
+}
