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
@@ -10,17 +10,8 @@ pub(super) fn draw_cell_picture(pg: &mut Page, data: &[u8], rect: (f32, f32, f32
     if w <= 0.0 || h <= 0.0 || data.len() > 16 * 1024 * 1024 {
         return;
     }
-    let Some((iw, ih)) = crate::io::image_size(data) else { return };
-    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 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 Some(image) = decode_limited(data, CELL_PICTURE_CAPS) else { return };
+    let (iw, ih) = (image.width(), image.height());
     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);
@@ -33,15 +24,44 @@ 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)> {
-    let (w, h) = crate::io::image_size(data)?;
-    if w == 0 || h == 0 || w as u64 * h as u64 > 60_000_000 {
+/// How large a picture the print path decodes: longest side, pixels, and bytes the decoder
+/// may allocate (which counts the bit depth and channels the header declares).
+#[derive(Clone, Copy)]
+pub(super) struct DecodeCaps {
+    pub(super) max_side: u32,
+    pub(super) max_pixels: u64,
+    pub(super) max_alloc: u64,
+}
+
+/// In-cell pictures: the same limits `insert.picture` accepts.
+const CELL_PICTURE_CAPS: DecodeCaps = DecodeCaps { max_side: 8192, max_pixels: 16_000_000, max_alloc: 64 * 1024 * 1024 };
+/// Floating pictures come from any opened file. 256 MiB holds a 60-megapixel 8-bit RGBA
+/// picture, but not a header declaring as many 16-bit pixels (twice that, before conversion).
+pub(super) const FLOATING_PICTURE_CAPS: DecodeCaps = DecodeCaps { max_side: 16_384, max_pixels: 60_000_000, max_alloc: 256 * 1024 * 1024 };
+
+/// Decodes a picture within `caps`. The size comes from the decoder's own header reader (not a
+/// separate sniffer that a crafted file can disagree with), and the decoder is given the same
+/// limits, so a picture over them is refused before its pixels are allocated.
+pub(super) fn decode_limited(data: &[u8], caps: DecodeCaps) -> Option<image::DynamicImage> {
+    let format = image::guess_format(data).ok()?;
+    let (w, h) = image::ImageReader::with_format(std::io::Cursor::new(data), format).into_dimensions().ok()?;
+    if w == 0 || h == 0 || w > caps.max_side || h > caps.max_side || u64::from(w) * u64::from(h) > caps.max_pixels {
         return None;
     }
-    let img = image::load_from_memory(data).ok()?;
+    let mut reader = image::ImageReader::with_format(std::io::Cursor::new(data), format);
+    let mut limits = image::Limits::default();
+    limits.max_image_width = Some(caps.max_side);
+    limits.max_image_height = Some(caps.max_side);
+    limits.max_alloc = Some(caps.max_alloc);
+    reader.limits(limits);
+    reader.decode().ok()
+}
+
+/// Decodes an embedded picture to RGBA, downscaled to at most `max_w`×`max_h` pixels.
+pub(super) fn decode_image(data: &[u8], max_w: u32, max_h: u32) -> Option<(Vec<u8>, u32, u32)> {
+    let img = decode_limited(data, FLOATING_PICTURE_CAPS)?;
     let img = if img.width() > max_w.max(1) || img.height() > max_h.max(1) { img.thumbnail(max_w.max(1), max_h.max(1)) } else { img };
-    let rgba = img.to_rgba8();
+    let rgba = img.into_rgba8();
     let (pw, ph) = (rgba.width(), rgba.height());
     Some((rgba.into_raw(), 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
@@ -565,3 +565,66 @@ fn rotated_wrapped_text_prints_rotated() {
     assert_eq!(up + down, blocks.len(), "a header line printed flat: {blocks:?}");
     assert!((1..=3).contains(&up) && (1..=3).contains(&down), "up {up}, down {down}: {blocks:?}");
 }
+
+fn png_of(img: image::DynamicImage) -> Vec<u8> {
+    let mut png = Vec::new();
+    img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
+    png
+}
+
+/// Rewrites a PNG's IHDR size (and its CRC) so the header declares `w`×`h` pixels.
+fn forge_png_size(png: &mut [u8], w: u32, h: u32) {
+    let ihdr = png.windows(4).position(|c| c == b"IHDR").unwrap();
+    png[ihdr + 4..ihdr + 8].copy_from_slice(&w.to_be_bytes());
+    png[ihdr + 8..ihdr + 12].copy_from_slice(&h.to_be_bytes());
+    let mut crc = !0u32;
+    for &b in &png[ihdr..ihdr + 17] {
+        crc ^= u32::from(b);
+        for _ in 0..8 {
+            crc = if crc & 1 == 1 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 };
+        }
+    }
+    png[ihdr + 17..ihdr + 21].copy_from_slice(&(!crc).to_be_bytes());
+}
+
+#[test]
+fn print_decodes_pictures_within_byte_limits() {
+    use super::objects::{DecodeCaps, FLOATING_PICTURE_CAPS, decode_image, decode_limited};
+    // The byte limit counts the bit depth and channels the header declares, not just pixels:
+    // 64×64 is 16 KiB as 8-bit RGBA but 32 KiB as 16-bit RGBA.
+    let rgba8 = png_of(image::DynamicImage::ImageRgba8(image::RgbaImage::new(64, 64)));
+    let rgba16 = png_of(image::DynamicImage::ImageRgba16(image::ImageBuffer::new(64, 64)));
+    let caps = DecodeCaps { max_side: 1024, max_pixels: 1_000_000, max_alloc: 24 * 1024 };
+    assert!(decode_limited(&rgba8, caps).is_some());
+    assert!(decode_limited(&rgba16, caps).is_none());
+    assert!(decode_limited(&rgba8, DecodeCaps { max_side: 32, ..caps }).is_none());
+    assert!(decode_limited(&rgba8, DecodeCaps { max_pixels: 64 * 64 - 1, ..caps }).is_none());
+    assert!(decode_limited(b"not a picture", caps).is_none());
+
+    // A header declaring 7000×7000 16-bit RGBA pixels (49 megapixels, under the pixel cap, but
+    // about 392 MB) is refused from its header, before anything that size is allocated.
+    let mut bomb = rgba16.clone();
+    forge_png_size(&mut bomb, 7000, 7000);
+    assert_eq!(image::ImageReader::with_format(std::io::Cursor::new(&bomb), image::ImageFormat::Png).into_dimensions().unwrap(), (7000, 7000));
+    assert!(decode_limited(&bomb, FLOATING_PICTURE_CAPS).is_none());
+    assert!(decode_image(&bomb, 100, 100).is_none());
+    assert!(decode_image(&rgba16, 100, 100).is_some_and(|(px, w, h)| (w, h) == (64, 64) && px.len() == 64 * 64 * 4));
+
+    // Printing a sheet holding such a picture leaves it out instead of failing.
+    let mut s = s();
+    let d = s.doc_mut().unwrap();
+    let wb = std::sync::Arc::make_mut(&mut d.wb);
+    let id = wb.next_object_id();
+    wb.sheet_mut(0).unwrap().images.push(Image {
+        id,
+        anchor: Anchor { cell: CellRef::new(1, 1), dx: 0.0, dy: 0.0, width: 40.0, height: 40.0, mode: gridcraft_model::AnchorMode::MoveAndSize },
+        data: bomb.into(),
+        mime: "image/png".into(),
+        alt: String::new(),
+    });
+    s.execute("cell.set", json!({"cell": "A1", "input": "Report"})).unwrap();
+    let (pdf, pages) = pdf_of(&mut s, json!({}));
+    assert_eq!(pages, 1);
+    assert!(contains(&pdf, "(Report) Tj"));
+    assert!(!contains(&pdf, "/Subtype /Image"));
+}
